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
+9689 -925
Interdiff #0 โ†’ #1
+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/ 19 21 # Created if following hacking.md 20 22 genjwks.out 21 23 /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 + }
+41 -4
appview/config/config.go
··· 4 4 "context" 5 5 "fmt" 6 6 "net/url" 7 + "strings" 7 8 "time" 8 9 9 10 "github.com/sethvargo/go-envconfig" ··· 88 89 AdminSecret string `env:"ADMIN_SECRET"` 89 90 } 90 91 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 + 91 112 type Cloudflare struct { 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"` 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"` 96 127 } 97 128 98 129 type LabelConfig struct { ··· 100 131 GoodFirstIssue string `env:"GFI, default=at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"` 101 132 } 102 133 134 + type BlueskyConfig struct { 135 + UpdateInterval time.Duration `env:"UPDATE_INTERVAL, default=1h"` 136 + } 137 + 103 138 type OgcardConfig struct { 104 139 Host string `env:"HOST, default=https://og.tangled.org"` 105 140 } ··· 133 168 Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 134 169 Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 135 170 Label LabelConfig `env:",prefix=TANGLED_LABEL_"` 171 + Bluesky BlueskyConfig `env:",prefix=TANGLED_BLUESKY_"` 172 + Sites SitesConfig `env:",prefix=TANGLED_SITES_"` 136 173 Ogcard OgcardConfig `env:",prefix=TANGLED_OGCARD_"` 137 174 } 138 175
+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 + 599 643 create table if not exists migrations ( 600 644 id integer primary key autoincrement, 601 645 name text unique ··· 615 659 create index if not exists idx_references_to_at on reference_links(to_at); 616 660 create index if not exists idx_webhooks_repo_at on webhooks(repo_at); 617 661 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); 618 663 `) 619 664 if err != nil { 620 665 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 - }
+7 -4
appview/filetree/filetree.go
··· 10 10 Name string 11 11 Path string 12 12 IsDirectory bool 13 + Level int 13 14 Children map[string]*FileTreeNode 14 15 } 15 16 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 { 18 19 return &FileTreeNode{ 19 20 Name: name, 20 21 Path: path, 21 22 IsDirectory: isDir, 23 + Level: level, 22 24 Children: make(map[string]*FileTreeNode), 23 25 } 24 26 } 25 27 26 28 func FileTree(files []string) *FileTreeNode { 29 + rootNode := newNode("", "", true, 0) 27 - rootNode := newNode("", "", true) 28 30 29 31 sort.Strings(files) 30 32 ··· 49 51 } 50 52 51 53 isDir := i < len(parts)-1 54 + level := i + 1 52 55 53 56 if _, exists := currentNode.Children[part]; !exists { 57 + currentNode.Children[part] = newNode(part, currentPath, isDir, level) 54 - currentNode.Children[part] = newNode(part, currentPath, isDir) 55 58 } 56 59 57 60 currentNode = currentNode.Children[part]
+31 -20
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) 92 93 93 94 err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ 94 95 "type": unicodenorm.Name, ··· 183 184 } 184 185 185 186 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"` 194 196 195 197 Comments []IssueCommentData `json:"comments"` 196 198 } 197 199 198 200 func makeIssueData(issue *models.Issue) *issueData { 199 201 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(), 208 211 } 209 212 } 210 213 ··· 284 287 musts = append(musts, bleveutil.KeywordFieldQuery("labels", label)) 285 288 } 286 289 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)) 289 292 } 290 293 291 294 for _, label := range opts.NegatedLabels { 292 295 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)) 293 304 } 294 305 295 306 indexerQuery := bleve.NewBooleanQuery()
+84 -4
appview/indexer/issues/indexer_test.go
··· 3 3 import ( 4 4 "context" 5 5 "os" 6 + "strings" 6 7 "testing" 7 8 8 9 "github.com/blevesearch/bleve/v2" ··· 38 39 39 40 func boolPtr(b bool) *bool { return &b } 40 41 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 { 42 45 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) 46 59 } 47 60 return state 48 61 } ··· 262 275 assert.Equal(t, uint64(0), result.Total) 263 276 assert.Empty(t, result.Hits) 264 277 } 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 + }
+31 -20
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) 87 88 88 89 err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ 89 90 "type": unicodenorm.Name, ··· 178 179 } 179 180 180 181 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"` 189 191 190 192 Comments []pullCommentData `json:"comments"` 191 193 } 192 194 193 195 func makePullData(pull *models.Pull) *pullData { 194 196 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(), 203 206 } 204 207 } 205 208 ··· 285 288 musts = append(musts, bleveutil.KeywordFieldQuery("labels", label)) 286 289 } 287 290 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)) 290 293 } 291 294 292 295 for _, label := range opts.NegatedLabels { 293 296 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)) 294 305 } 295 306 296 307 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{
+40 -41
appview/issues/issues.go
··· 830 830 query.Set("state", "open") 831 831 } 832 832 833 - var authorDid string 834 - if authorHandle := query.Get("author"); authorHandle != nil { 835 - identity, err := rp.idResolver.ResolveIdent(r.Context(), *authorHandle) 833 + resolve := func(ctx context.Context, ident string) (string, error) { 834 + id, err := rp.idResolver.ResolveIdent(ctx, ident) 836 835 if err != nil { 837 - l.Debug("failed to resolve author handle", "handle", *authorHandle, "err", err) 838 - } else { 839 - authorDid = identity.DID.String() 836 + return "", err 840 837 } 838 + return id.DID.String(), nil 841 839 } 842 840 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 - } 841 + authorDid, negatedAuthorDids := searchquery.ResolveAuthor(r.Context(), query, resolve, l) 852 842 853 843 labels := query.GetAll("label") 854 844 negatedLabels := query.GetAllNegated("label") 845 + labelValues := query.GetDynamicTags() 846 + negatedLabelValues := query.GetNegatedDynamicTags() 855 847 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) 848 + // resolve DID-format label values: if a dynamic tag's label 849 + // definition has format "did", resolve the handle to a DID 850 + if len(labelValues) > 0 || len(negatedLabelValues) > 0 { 851 + labelDefs, err := db.GetLabelDefinitions( 852 + rp.db, 853 + orm.FilterIn("at_uri", f.Labels), 854 + orm.FilterContains("scope", tangled.RepoIssueNSID), 855 + ) 856 + if err == nil { 857 + didLabels := make(map[string]bool) 858 + for _, def := range labelDefs { 859 + if def.ValueType.Format == models.ValueTypeFormatDid { 860 + didLabels[def.Name] = true 861 + } 871 862 } 863 + labelValues = searchquery.ResolveDIDLabelValues(r.Context(), labelValues, didLabels, resolve, l) 864 + negatedLabelValues = searchquery.ResolveDIDLabelValues(r.Context(), negatedLabelValues, didLabels, resolve, l) 865 + } else { 866 + l.Debug("failed to fetch label definitions for DID resolution", "err", err) 872 867 } 873 868 } 874 869 870 + tf := searchquery.ExtractTextFilters(query) 871 + 875 872 searchOpts := models.IssueSearchOptions{ 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, 873 + Keywords: tf.Keywords, 874 + Phrases: tf.Phrases, 875 + RepoAt: f.RepoAt().String(), 876 + IsOpen: isOpen, 877 + AuthorDid: authorDid, 878 + Labels: labels, 879 + LabelValues: labelValues, 880 + NegatedKeywords: tf.NegatedKeywords, 881 + NegatedPhrases: tf.NegatedPhrases, 882 + NegatedLabels: negatedLabels, 883 + NegatedLabelValues: negatedLabelValues, 884 + NegatedAuthorDids: negatedAuthorDids, 885 + Page: page, 887 886 } 888 887 889 888 totalIssues := 0
appview/issues/opengraph.go

This file has not been changed.

+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 + 317 338 func (s LabelState) Inner() map[string]set { 318 339 return s.inner 319 340 }
+28 -22
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 12 13 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 17 19 18 20 Page pagination.Page 19 21 } 20 22 21 23 func (o *IssueSearchOptions) HasSearchFilters() bool { 22 24 return len(o.Keywords) > 0 || len(o.Phrases) > 0 || 25 + o.AuthorDid != "" || len(o.NegatedAuthorDids) > 0 || 23 - o.AuthorDid != "" || o.NegatedAuthorDid != "" || 24 26 len(o.Labels) > 0 || len(o.NegatedLabels) > 0 || 27 + len(o.LabelValues) > 0 || len(o.NegatedLabelValues) > 0 || 25 28 len(o.NegatedKeywords) > 0 || len(o.NegatedPhrases) > 0 26 29 } 27 30 28 31 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 35 39 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 40 45 41 46 Page pagination.Page 42 47 } 43 48 44 49 func (o *PullSearchOptions) HasSearchFilters() bool { 45 50 return len(o.Keywords) > 0 || len(o.Phrases) > 0 || 51 + o.AuthorDid != "" || len(o.NegatedAuthorDids) > 0 || 46 - o.AuthorDid != "" || o.NegatedAuthorDid != "" || 47 52 len(o.Labels) > 0 || len(o.NegatedLabels) > 0 || 53 + len(o.LabelValues) > 0 || len(o.NegatedLabelValues) > 0 || 48 54 len(o.NegatedKeywords) > 0 || len(o.NegatedPhrases) > 0 49 55 }
+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)
+159 -13
appview/oauth/handler.go
··· 6 6 "encoding/json" 7 7 "errors" 8 8 "fmt" 9 + "log/slog" 9 10 "net/http" 10 11 "slices" 12 + "strings" 11 13 "time" 12 14 13 15 comatproto "github.com/bluesky-social/indigo/api/atproto" 14 16 "github.com/bluesky-social/indigo/atproto/auth/oauth" 15 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 + xrpc "github.com/bluesky-social/indigo/xrpc" 16 19 "github.com/go-chi/chi/v5" 17 20 "github.com/posthog/posthog-go" 18 21 "tangled.org/core/api/tangled" 19 22 "tangled.org/core/appview/db" 20 23 "tangled.org/core/appview/models" 21 24 "tangled.org/core/consts" 25 + "tangled.org/core/idresolver" 22 26 "tangled.org/core/orm" 23 27 "tangled.org/core/tid" 24 28 ) ··· 37 41 doc.JWKSURI = &o.JwksUri 38 42 doc.ClientName = &o.ClientName 39 43 doc.ClientURI = &o.ClientUri 44 + doc.Scope = doc.Scope + " identity:handle" 40 45 41 46 w.Header().Set("Content-Type", "application/json") 42 47 if err := json.NewEncoder(w).Encode(doc); err != nil { ··· 89 94 go o.addToDefaultKnot(sessData.AccountDID.String()) 90 95 go o.addToDefaultSpindle(sessData.AccountDID.String()) 91 96 go o.ensureTangledProfile(sessData) 97 + go o.autoClaimTnglShDomain(sessData.AccountDID.String()) 92 98 93 99 if !o.Config.Core.Dev { 94 100 err = o.Posthog.Enqueue(posthog.Capture{ ··· 105 111 redirectURL = authReturn.ReturnURL 106 112 } 107 113 114 + if o.isAccountDeactivated(sessData) { 115 + redirectURL = "/settings/profile" 116 + } 117 + 108 118 http.Redirect(w, r, redirectURL, http.StatusFound) 109 119 } 110 120 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 + 111 143 func (o *OAuth) addToDefaultSpindle(did string) { 112 144 l := o.Logger.With("subject", did) 113 145 ··· 129 161 } 130 162 131 163 l.Debug("adding to default spindle") 164 + session, err := o.getAppPasswordSession() 132 - session, err := o.createAppPasswordSession(o.Config.Core.AppPassword, consts.TangledDid) 133 165 if err != nil { 134 166 l.Error("failed to create session", "err", err) 135 167 return 136 168 } 137 169 138 170 record := tangled.SpindleMember{ 171 + LexiconTypeID: tangled.SpindleMemberNSID, 139 - LexiconTypeID: "sh.tangled.spindle.member", 140 172 Subject: did, 141 173 Instance: consts.DefaultSpindle, 142 174 CreatedAt: time.Now().Format(time.RFC3339), ··· 168 200 } 169 201 170 202 l.Debug("adding to default knot") 203 + session, err := o.getAppPasswordSession() 171 - session, err := o.createAppPasswordSession(o.Config.Core.TmpAltAppPassword, consts.IcyDid) 172 204 if err != nil { 173 205 l.Error("failed to create session", "err", err) 174 206 return 175 207 } 176 208 177 209 record := tangled.KnotMember{ 210 + LexiconTypeID: tangled.KnotMemberNSID, 178 - LexiconTypeID: "sh.tangled.knot.member", 179 211 Subject: did, 180 212 Domain: consts.DefaultKnot, 181 213 CreatedAt: time.Now().Format(time.RFC3339), ··· 191 223 return 192 224 } 193 225 226 + l.Debug("successfully added to default knot") 194 - l.Debug("successfully addeds to default Knot") 195 227 } 196 228 197 229 func (o *OAuth) ensureTangledProfile(sessData *oauth.ClientSessionData) { ··· 241 273 l.Debug("successfully created empty Tangled profile on PDS and DB") 242 274 } 243 275 276 + // create a AppPasswordSession using apppasswords 277 + type AppPasswordSession struct { 244 - // create a session using apppasswords 245 - type session struct { 246 278 AccessJwt string `json:"accessJwt"` 279 + RefreshJwt string `json:"refreshJwt"` 247 280 PdsEndpoint string 248 281 Did string 282 + Logger *slog.Logger 283 + ExpiresAt time.Time 249 284 } 250 285 286 + func CreateAppPasswordSession(res *idresolver.Resolver, appPassword, did string, logger *slog.Logger) (*AppPasswordSession, error) { 251 - func (o *OAuth) createAppPasswordSession(appPassword, did string) (*session, error) { 252 287 if appPassword == "" { 288 + return nil, fmt.Errorf("no app password configured") 253 - return nil, fmt.Errorf("no app password configured, skipping member addition") 254 289 } 255 290 291 + resolved, err := res.ResolveIdent(context.Background(), did) 256 - resolved, err := o.IdResolver.ResolveIdent(context.Background(), did) 257 292 if err != nil { 258 293 return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 259 294 } ··· 279 314 } 280 315 sessionReq.Header.Set("Content-Type", "application/json") 281 316 317 + logger.Debug("creating app password session", "url", sessionURL, "headers", sessionReq.Header) 318 + 282 319 client := &http.Client{Timeout: 30 * time.Second} 283 320 sessionResp, err := client.Do(sessionReq) 284 321 if err != nil { ··· 290 327 return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 291 328 } 292 329 330 + var session AppPasswordSession 293 - var session session 294 331 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 295 332 return nil, fmt.Errorf("failed to decode session response: %v", err) 296 333 } 297 334 298 335 session.PdsEndpoint = pdsEndpoint 299 336 session.Did = did 337 + session.Logger = logger 338 + session.ExpiresAt = time.Now().Add(115 * time.Minute) 300 339 301 340 return &session, nil 302 341 } 303 342 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 { 305 400 recordBytes, err := json.Marshal(record) 306 401 if err != nil { 307 402 return fmt.Errorf("failed to marshal knot member record: %w", err) ··· 328 423 req.Header.Set("Content-Type", "application/json") 329 424 req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 330 425 426 + s.Logger.Debug("putting record", "url", url, "collection", collection) 427 + 331 428 client := &http.Client{Timeout: 30 * time.Second} 332 429 resp, err := client.Do(req) 333 430 if err != nil { ··· 336 433 defer resp.Body.Close() 337 434 338 435 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) 340 441 } 341 442 342 443 return nil 343 444 } 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 + }
+63 -1
appview/oauth/oauth.go
··· 1 1 package oauth 2 2 3 3 import ( 4 + "context" 4 5 "errors" 5 6 "fmt" 6 7 "log/slog" 7 8 "net/http" 9 + "net/url" 10 + "sync" 8 11 "time" 9 12 10 13 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 33 36 Enforcer *rbac.Enforcer 34 37 IdResolver *idresolver.Resolver 35 38 Logger *slog.Logger 39 + 40 + appPasswordSession *AppPasswordSession 41 + appPasswordSessionMu sync.Mutex 36 42 } 37 43 38 44 func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, res *idresolver.Resolver, logger *slog.Logger) (*OAuth, error) { ··· 100 106 func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 101 107 userSession, err := o.SessStore.Get(r, SessionName) 102 108 if err != nil { 109 + o.Logger.Warn("failed to decode existing session cookie, will create new", "err", err) 103 - return err 104 110 } 105 111 106 112 userSession.Values[SessionDid] = sessData.AccountDID.String() ··· 359 365 }, 360 366 }, nil 361 367 } 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 + }
appview/ogcard/.gitignore

This file has not been changed.

appview/ogcard/card.go

This file has not been changed.

appview/ogcard/client.go

This file has not been changed.

appview/ogcard/package.json

This file has not been changed.

appview/ogcard/src/__tests__/assets/avatar.jpg

Failed to calculate interdiff for this file.

appview/ogcard/src/__tests__/fixtures.ts

This file has not been changed.

appview/ogcard/src/__tests__/render.test.ts

This file has not been changed.

appview/ogcard/src/components/cards/issue.tsx

This file has not been changed.

appview/ogcard/src/components/cards/pull-request.tsx

This file has not been changed.

appview/ogcard/src/components/cards/repository.tsx

This file has not been changed.

appview/ogcard/src/components/shared/avatar.tsx

This file has not been changed.

appview/ogcard/src/components/shared/card-header.tsx

This file has not been changed.

appview/ogcard/src/components/shared/constants.ts

This file has not been changed.

appview/ogcard/src/components/shared/footer-stats.tsx

This file has not been changed.

appview/ogcard/src/components/shared/label-pill.tsx

This file has not been changed.

appview/ogcard/src/components/shared/language-circles.tsx

This file has not been changed.

appview/ogcard/src/components/shared/layout.tsx

This file has not been changed.

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

This file has not been changed.

appview/ogcard/src/components/shared/metrics.tsx

This file has not been changed.

appview/ogcard/src/components/shared/stat-item.tsx

This file has not been changed.

appview/ogcard/src/components/shared/status-badge.tsx

This file has not been changed.

appview/ogcard/src/icons/lucide.tsx

This file has not been changed.

appview/ogcard/src/index.tsx

This file has not been changed.

appview/ogcard/src/lib/fonts.ts

This file has not been changed.

appview/ogcard/src/lib/render.ts

This file has not been changed.

appview/ogcard/src/validation.ts

This file has not been changed.

appview/ogcard/tsconfig.json

This file has not been changed.

appview/ogcard/wrangler.jsonc

This file has not been changed.

+31 -7
appview/pages/funcmap.go
··· 12 12 "html/template" 13 13 "log" 14 14 "math" 15 + "math/rand" 15 16 "net/url" 16 17 "path/filepath" 17 18 "reflect" ··· 123 124 }, 124 125 "mod": func(a, b int) int { 125 126 return a % b 127 + }, 128 + "randInt": func(bound int) int { 129 + return rand.Intn(bound) 126 130 }, 127 131 "f64": func(a int) float64 { 128 132 return float64(a) ··· 291 295 292 296 lexer := lexers.Get(filepath.Base(path)) 293 297 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 { 294 311 lexer = lexers.Fallback 295 312 } 296 313 ··· 449 466 } 450 467 return result 451 468 }, 469 + "isGenerated": func(path string) bool { 470 + return enry.IsGenerated(path, nil) 471 + }, 452 472 // constant values used to define a template 453 473 "const": func() map[string]any { 454 474 return map[string]any{ ··· 461 481 {"Name": "notifications", "Icon": "bell"}, 462 482 {"Name": "knots", "Icon": "volleyball"}, 463 483 {"Name": "spindles", "Icon": "spool"}, 484 + {"Name": "sites", "Icon": "globe"}, 464 485 }, 465 486 "RepoSettingsTabs": []tab{ 466 487 {"Name": "general", "Icon": "sliders-horizontal"}, 467 488 {"Name": "access", "Icon": "users"}, 468 489 {"Name": "pipelines", "Icon": "layers-2"}, 469 490 {"Name": "hooks", "Icon": "webhook"}, 491 + {"Name": "sites", "Icon": "globe"}, 470 492 }, 471 493 } 472 494 }, ··· 504 526 signature := hex.EncodeToString(h.Sum(nil)) 505 527 506 528 // Get avatar CID for cache busting 507 - profile, err := db.GetProfile(p.db, did) 508 529 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 515 539 } 516 540 } 517 541
+12 -2
appview/pages/htmx.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "html" 5 6 "net/http" 6 7 ) 7 8 8 9 // Notice performs a hx-oob-swap to replace the content of an element with a message. 9 10 // Pass the id of the element and the message to display. 10 11 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) 12 22 13 23 w.Header().Set("Content-Type", "text/html") 14 24 w.WriteHeader(http.StatusOK) 25 + w.Write([]byte(markup)) 15 - w.Write([]byte(html)) 16 26 } 17 27 18 28 // 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 + }
+30 -18
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" 25 26 htmlparse "golang.org/x/net/html" 26 27 27 28 "tangled.org/core/api/tangled" ··· 52 53 Files fs.FS 53 54 } 54 55 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")), 65 67 ), 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, 73 72 ), 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...), 74 81 goldmark.WithParserOptions( 75 82 parser.WithAutoHeadingID(), 76 83 ), 77 84 goldmark.WithRendererOptions(html.WithUnsafe()), 78 85 ) 79 86 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...) 80 92 } 81 93 82 94 func (rctx *RenderContext) RenderMarkdown(source string) string {
+46
appview/pages/markup/markdown_test.go
··· 2 2 3 3 import ( 4 4 "bytes" 5 + "strings" 5 6 "testing" 6 7 ) 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 + } 7 53 8 54 func TestAtExtension_Rendering(t *testing.T) { 9 55 tests := []struct {
+16 -5
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 + 13 24 type Sanitizer struct { 14 25 defaultPolicy *bluemonday.Policy 15 26 descriptionPolicy *bluemonday.Policy ··· 17 28 18 29 func NewSanitizer() Sanitizer { 19 30 return Sanitizer{ 31 + defaultPolicy: sharedDefaultPolicy, 32 + descriptionPolicy: sharedDescriptionPolicy, 20 - defaultPolicy: defaultPolicy(), 21 - descriptionPolicy: descriptionPolicy(), 22 33 } 23 34 } 24 35 ··· 29 40 return s.descriptionPolicy.Sanitize(html) 30 41 } 31 42 43 + func buildDefaultPolicy() *bluemonday.Policy { 32 - func defaultPolicy() *bluemonday.Policy { 33 44 policy := bluemonday.UGCPolicy() 34 45 35 46 // Allow generally safe attributes ··· 72 83 policy.AllowAttrs("checked", "disabled", "data-source-position").OnElements("input") 73 84 74 85 // for code blocks 86 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`chroma|mermaid`)).OnElements("pre") 75 - policy.AllowAttrs("class").Matching(regexp.MustCompile(`chroma`)).OnElements("pre") 76 87 policy.AllowAttrs("class").Matching(regexp.MustCompile(`anchor|footnote-ref|footnote-backref`)).OnElements("a") 77 88 policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8") 78 89 policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span") ··· 123 134 return policy 124 135 } 125 136 137 + func buildDescriptionPolicy() *bluemonday.Policy { 126 - func descriptionPolicy() *bluemonday.Policy { 127 138 policy := bluemonday.NewPolicy() 128 139 policy.AllowStandardURLs() 129 140
+109 -1
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 + 91 150 func (p *Pages) fragmentPaths() ([]string, error) { 92 151 var fragmentPaths []string 93 152 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { ··· 260 319 261 320 type SignupParams struct { 262 321 CloudflareSiteKey string 322 + EmailId string 263 323 } 264 324 265 325 func (p *Pages) Signup(w io.Writer, params SignupParams) error { ··· 339 399 Timeline []models.TimelineEvent 340 400 Repos []models.Repo 341 401 GfiLabel *models.LabelDefinition 402 + BlueskyPosts []models.BskyPost 342 403 } 343 404 344 405 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { ··· 362 423 LoggedInUser *oauth.MultiAccountUser 363 424 Tab string 364 425 PunchcardPreference models.PunchcardPreference 426 + IsTnglSh bool 427 + IsDeactivated bool 428 + PdsDomain string 429 + HandleOpen bool 365 430 } 366 431 367 432 func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { ··· 430 495 return p.execute("user/settings/notifications", w, params) 431 496 } 432 497 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 + 433 511 type UpgradeBannerParams struct { 434 512 Registrations []models.Registration 435 513 Spindles []models.Spindle ··· 776 854 RepoInfo repoinfo.RepoInfo 777 855 Active string 778 856 BreadCrumbs [][]string 857 + Path string 779 - TreePath string 780 858 Raw bool 781 859 HTMLReadme template.HTML 782 860 EmailToDid map[string]string ··· 1001 1079 return tpl.ExecuteTemplate(w, "repo/settings/fragments/webhookDeliveries", params) 1002 1080 } 1003 1081 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 + 1004 1100 type RepoIssuesParams struct { 1005 1101 LoggedInUser *oauth.MultiAccountUser 1006 1102 RepoInfo repoinfo.RepoInfo ··· 1538 1634 } 1539 1635 1540 1636 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) 1541 1649 } 1542 1650 1543 1651 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 }}
+19 -2
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> 42 59 <title>{{ block "title" . }}{{ end }}</title> 43 60 {{ block "extrameta" . }}{{ end }} 44 61 </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"> 46 63 {{ 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;"> 48 65 49 66 {{ if .LoggedInUser }} 50 67 <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">
+4 -3
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" . }} 155 156 {{ end }}
+1 -5
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 }} 135 131 136 132 {{ define "contentAfter" }} 137 133 {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }}
+1 -5
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 }} 34 30 35 31 {{ define "contentAfter" }} 36 32 {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }}
+90 -20
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" }} 14 18 {{ end }} 15 19 16 20 {{ define "diffTopbar" }} ··· 84 88 {{ $id := index . 0 }} 85 89 {{ $target := index . 1 }} 86 90 {{ $direction := index . 2 }} 91 + <div id="{{ $id }}" 87 - <div id="{{ $id }}" 88 92 data-resizer="vertical" 89 93 data-target="{{ $target }}" 90 94 data-direction="{{ $direction }}" ··· 137 141 {{ $idx := index . 0 }} 138 142 {{ $file := index . 1 }} 139 143 {{ $isSplit := index . 2 }} 144 + {{ $isGenerated := false }} 140 145 {{ 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 }}"> 142 153 <summary class="list-none cursor-pointer sticky top-12 group-open:border-b border-gray-200 dark:border-gray-700"> 143 154 <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 144 155 <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> ··· 147 158 {{ template "repo/fragments/diffStatPill" .Stats }} 148 159 149 160 <div class="flex gap-2 items-center overflow-x-auto"> 150 - {{ $n := .Names }} 151 161 {{ if and $n.New $n.Old (ne $n.New $n.Old)}} 152 162 {{ $n.Old }} {{ i "arrow-right" "w-4 h-4" }} {{ $n.New }} 153 163 {{ else if $n.New }} 154 164 {{ $n.New }} 155 165 {{ else }} 156 166 {{ $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> 157 172 {{ end }} 158 173 </div> 159 174 </div> ··· 204 219 </span> 205 220 </label> 206 221 <script> 222 + (() => { 207 - document.addEventListener('DOMContentLoaded', function() { 208 223 const checkbox = document.getElementById('collapseToggle'); 224 + const diffArea = document.getElementById('diff-area'); 209 - const details = document.querySelectorAll('details[id^="file-"]'); 210 225 226 + checkbox.addEventListener('change', () => { 227 + document.querySelectorAll('details[id^="file-"]').forEach(detail => { 211 - checkbox.addEventListener('change', function() { 212 - details.forEach(detail => { 213 228 detail.open = checkbox.checked; 214 229 }); 215 230 }); 216 231 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); 221 267 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; 226 291 } 292 + return false; 227 293 }); 294 + }; 295 + 296 + window.__activeFileScrollHandler = updateActiveFile; 297 + document.addEventListener('scroll', updateActiveFile); 298 + updateActiveFile(); 299 + })(); 228 - }); 229 - }); 230 300 </script> 231 301 {{ end }}
+27 -19
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 }} 20 9 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) }} 26 35 {{ end }} 27 -
+13 -4
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 + */}} 2 10 {{ if and .Name .IsDirectory }} 11 + <details open class="group/level-{{ .Level }}"> 3 - <details open> 4 12 <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" }} 7 16 <span class="filename truncate text-black dark:text-white">{{ .Name }}</span> 8 17 </span> 9 18 </summary> ··· 16 25 {{ else if .Name }} 17 26 <div class="tree-file flex items-center gap-2 pt-1"> 18 27 {{ 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> 20 29 </div> 21 30 {{ else }} 22 31 {{ 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 }}" 25 26 hx-include="#edit-textarea-{{ .Comment.Id }}" 26 27 hx-target="#comment-body-{{ .Comment.Id }}" 27 28 hx-swap="outerHTML">
+8 -2
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" 6 9 hx-on::after-request="if(event.detail.successful) this.reset()" 7 10 > 8 11 <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full"> ··· 24 27 <div class="flex gap-2 mt-2"> 25 28 <button 26 29 id="comment-button" 27 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 28 30 type="submit" 29 - hx-disabled-elt="#comment-button" 30 31 class="btn-create p-2 flex items-center gap-2 no-underline hover:no-underline group" 31 32 disabled 32 33 > ··· 101 102 {{ end }} 102 103 103 104 <script> 105 + function commentButtonEnabled() { 106 + const commentButton = document.getElementById('comment-button'); 107 + return !commentButton.hasAttribute('disabled'); 108 + } 109 + 104 110 function updateCommentForm() { 105 111 const textarea = document.getElementById('comment-textarea'); 106 112 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)" 9 10 hx-swap="none" 10 11 hx-indicator="#spinner"> 11 12 <div class="flex flex-col gap-2">
+2 -5
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" 6 7 hx-on::after-request="if(event.detail.successful) this.reset()" 7 8 hx-disabled-elt="#reply-{{ .Comment.Id }}" 8 9 > ··· 13 14 class="w-full p-2" 14 15 placeholder="Leave a reply..." 15 16 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> 21 18 22 19 <input 23 20 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" 8 9 hx-swap="none" 9 10 hx-on::after-request="if(event.detail.successful) this.reset()" 10 11 hx-disabled-elt="#reply-{{ .RoundNumber }}" 11 12 class="w-full flex flex-wrap gap-2 group" 12 13 > 13 14 <textarea 15 + id="pull-comment-textarea" 14 16 name="body" 15 17 class="w-full p-2 rounded border" 16 18 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)" 10 11 hx-indicator="#create-pull-spinner" 11 12 hx-swap="none" 12 13 >
+18 -7
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 + 157 167 <!-- backdrop overlay - only visible on mobile when open --> 158 168 <div id="bottomSheetBackdrop" class="fixed inset-0 bg-black/50 md:hidden opacity-0 pointer-events-none transition-opacity duration-300 z-40"></div> 159 169 <!-- right panel - bottom sheet on mobile, side panel on desktop --> ··· 163 173 flex gap-4 items-center justify-between 164 174 rounded-t-2xl md:rounded-t cursor-pointer list-none p-4 md:h-12 165 175 text-white md:text-black md:dark:text-white 176 + {{ $bgColor }} 166 - bg-green-600 dark:bg-green-700 167 177 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"> 170 179 <h2 class="">History</h2> 171 180 {{ template "subsPanelSummary" $ }} 172 181 </summary> ··· 252 261 {{ $root := index . 3 }} 253 262 {{ $round := $item.RoundNumber }} 254 263 <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 256 265 {{ if eq $round 0 }}rounded-b{{ else }}rounded{{ end }} 257 266 {{ if eq $round $root.ActiveRound }} 258 267 border-blue-200 dark:border-blue-700 ··· 271 280 {{ $root := index . 3 }} 272 281 {{ $round := $item.RoundNumber }} 273 282 <div class=" 283 + {{ if ne $round 0 }}rounded-t{{ end }} 274 - {{ if eq $round 0 }}rounded-b{{ else }}rounded{{ end }} 275 284 px-6 py-4 pr-2 pt-2 276 285 bg-white dark:bg-gray-800 286 + 277 287 {{ 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 279 289 {{ else }} 290 + border-t border-t-gray-200 dark:border-t-gray-700 280 - border-b-2 border-gray-200 dark:border-gray-700 281 291 {{ end }} 292 + 282 293 flex gap-2 sticky top-0 z-20"> 283 294 <!-- left column: just profile picture --> 284 295 <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 }}
+4 -3
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" . }} 102 103 {{end}} 103 104 104 105 {{ define "repoAfter" }}
+27 -30
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> 32 29 </div> 33 30 {{ 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 }}
+490 -66
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 }} 22 23 23 24 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> 24 34 {{ end }} 25 35 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 }} 26 48 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" . }} 34 423 <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 37 426 {{ i "arrow-right" "size-4" }} 38 427 </a> 39 428 </div> 40 429 </div> 41 430 {{ end }} 42 431 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> 60 442 </div> 443 + {{ end }} 61 444 </div> 445 + </div> 62 446 {{ end }} 63 447 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 - ) }} 79 457 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 - ) }} 92 482 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 - ) }} 105 529 </div> 106 530 {{ 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 - 16 11 {{ template "timeline/fragments/goodfirstissues" . }} 17 12 {{ template "timeline/fragments/trending" . }} 18 13 {{ 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> 101 102 <div id="settings-keys" class="text-red-500 dark:text-red-400"></div> 102 103 </form> 103 104 {{ end }}
+2 -3
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> 186 185 </form> 187 186 {{ end }}
+242 -2
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 }} 14 17 {{ template "punchcard" . }} 15 18 </div> 16 19 </section> ··· 26 29 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 27 30 <div class="flex flex-col gap-1 p-4"> 28 31 <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> 30 45 </div> 31 46 <div class="flex flex-col gap-1 p-4"> 32 47 <span class="text-sm text-gray-500 dark:text-gray-400">Decentralized Identifier (DID)</span> ··· 37 52 <span class="font-bold">{{ .LoggedInUser.Pds }}</span> 38 53 </div> 39 54 </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> 40 280 </div> 41 281 {{ end }} 42 282 ··· 46 286 <p class="text-gray-500 dark:text-gray-400 pb-2 "> 47 287 Configure punchcard visibility and preferences. 48 288 </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"> 50 290 <div class="flex items-center gap-2"> 51 291 <input type="checkbox" id="hideMine" name="hideMine" value="on" {{ if eq true $.PunchcardPreference.HideMine }}checked{{ end }}> 52 292 <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 }}" 23 24 /> 24 25 </div> 25 26 <span class="text-sm text-gray-500 mt-1">
appview/pulls/opengraph.go

This file has not been changed.

+40 -41
appview/pulls/pulls.go
··· 563 563 query.Set("state", "open") 564 564 } 565 565 566 - var authorDid string 567 - if authorHandle := query.Get("author"); authorHandle != nil { 568 - identity, err := s.idResolver.ResolveIdent(r.Context(), *authorHandle) 566 + resolve := func(ctx context.Context, ident string) (string, error) { 567 + id, err := s.idResolver.ResolveIdent(ctx, ident) 569 568 if err != nil { 570 - l.Debug("failed to resolve author handle", "handle", *authorHandle, "err", err) 571 - } else { 572 - authorDid = identity.DID.String() 569 + return "", err 573 570 } 571 + return id.DID.String(), nil 574 572 } 575 573 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 - } 574 + authorDid, negatedAuthorDids := searchquery.ResolveAuthor(r.Context(), query, resolve, l) 585 575 586 576 labels := query.GetAll("label") 587 577 negatedLabels := query.GetAllNegated("label") 578 + labelValues := query.GetDynamicTags() 579 + negatedLabelValues := query.GetNegatedDynamicTags() 588 580 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) 581 + // resolve DID-format label values: if a dynamic tag's label 582 + // definition has format "did", resolve the handle to a DID 583 + if len(labelValues) > 0 || len(negatedLabelValues) > 0 { 584 + labelDefs, err := db.GetLabelDefinitions( 585 + s.db, 586 + orm.FilterIn("at_uri", f.Labels), 587 + orm.FilterContains("scope", tangled.RepoPullNSID), 588 + ) 589 + if err == nil { 590 + didLabels := make(map[string]bool) 591 + for _, def := range labelDefs { 592 + if def.ValueType.Format == models.ValueTypeFormatDid { 593 + didLabels[def.Name] = true 594 + } 604 595 } 596 + labelValues = searchquery.ResolveDIDLabelValues(r.Context(), labelValues, didLabels, resolve, l) 597 + negatedLabelValues = searchquery.ResolveDIDLabelValues(r.Context(), negatedLabelValues, didLabels, resolve, l) 598 + } else { 599 + l.Debug("failed to fetch label definitions for DID resolution", "err", err) 605 600 } 606 601 } 607 602 603 + tf := searchquery.ExtractTextFilters(query) 604 + 608 605 searchOpts := models.PullSearchOptions{ 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, 606 + Keywords: tf.Keywords, 607 + Phrases: tf.Phrases, 608 + RepoAt: f.RepoAt().String(), 609 + State: state, 610 + AuthorDid: authorDid, 611 + Labels: labels, 612 + LabelValues: labelValues, 613 + NegatedKeywords: tf.NegatedKeywords, 614 + NegatedPhrases: tf.NegatedPhrases, 615 + NegatedLabels: negatedLabels, 616 + NegatedLabelValues: negatedLabelValues, 617 + NegatedAuthorDids: negatedAuthorDids, 618 + Page: page, 620 619 } 621 620 622 621 var totalPulls int
+12 -8
appview/repo/archive.go
··· 52 52 rp.pages.Error503(w) 53 53 return 54 54 } 55 + defer resp.Body.Close() 55 56 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) 59 63 } 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) 62 66 } 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 - } 66 70 if link := resp.Header.Get("Link"); link != "" { 67 71 if resolvedRef, err := extractImmutableLink(link); err == nil { 68 72 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
appview/repo/opengraph.go

This file has not been changed.

+7 -1
appview/repo/repo.go
··· 12 12 "strings" 13 13 "time" 14 14 15 + "tangled.org/core/appview/cloudflare" 16 + 15 17 "tangled.org/core/api/tangled" 16 18 "tangled.org/core/appview/config" 17 19 "tangled.org/core/appview/db" ··· 51 53 logger *slog.Logger 52 54 serviceAuth *serviceauth.ServiceAuth 53 55 validator *validator.Validator 56 + cfClient *cloudflare.Client 54 57 ogcardClient *ogcard.Client 55 58 } 56 59 ··· 66 69 enforcer *rbac.Enforcer, 67 70 logger *slog.Logger, 68 71 validator *validator.Validator, 72 + cfClient *cloudflare.Client, 69 73 ) *Repo { 70 - return &Repo{oauth: oauth, 74 + return &Repo{ 75 + oauth: oauth, 71 76 repoResolver: repoResolver, 72 77 pages: pages, 73 78 idResolver: idResolver, ··· 78 83 enforcer: enforcer, 79 84 logger: logger, 80 85 validator: validator, 86 + cfClient: cfClient, 81 87 ogcardClient: ogcard.NewClient(config.Ogcard.Host), 82 88 } 83 89 }
+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 + }) 90 94 r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/hooks", func(r chi.Router) { 91 95 r.Get("/", rp.Webhooks) 92 96 r.Post("/", rp.AddWebhook)
+207
appview/repo/settings.go
··· 1 1 package repo 2 2 3 3 import ( 4 + "context" 4 5 "encoding/json" 5 6 "fmt" 6 7 "net/http" 8 + "path" 7 9 "slices" 8 10 "strings" 9 11 "time" 10 12 11 13 "tangled.org/core/api/tangled" 14 + 12 15 "tangled.org/core/appview/db" 13 16 "tangled.org/core/appview/models" 14 17 "tangled.org/core/appview/oauth" 15 18 "tangled.org/core/appview/pages" 19 + "tangled.org/core/appview/sites" 16 20 xrpcclient "tangled.org/core/appview/xrpcclient" 17 21 "tangled.org/core/orm" 18 22 "tangled.org/core/types" ··· 170 174 171 175 case "hooks": 172 176 rp.Webhooks(w, r) 177 + 178 + case "sites": 179 + rp.sitesSettings(w, r) 173 180 } 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) 174 381 } 175 382 176 383 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 + 21 27 type RepoResolver struct { 22 28 config *config.Config 23 29 enforcer *rbac.Enforcer ··· 140 146 func extractCurrentDir(fullPath string) string { 141 147 fullPath = strings.TrimPrefix(fullPath, "/") 142 148 143 - blobPattern := regexp.MustCompile(`blob/[^/]+/(.*)$`) 144 149 if matches := blobPattern.FindStringSubmatch(fullPath); len(matches) > 1 { 145 150 return path.Dir(matches[1]) 146 151 } 147 152 148 - treePattern := regexp.MustCompile(`tree/[^/]+/(.*)$`) 149 153 if matches := treePattern.FindStringSubmatch(fullPath); len(matches) > 1 { 150 154 dir := strings.TrimSuffix(matches[1], "/") 151 155 if dir == "" { ··· 164 168 func extractPathAfterRef(fullPath string) string { 165 169 fullPath = strings.TrimPrefix(fullPath, "/") 166 170 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 + 161 194 func (q *Query) Set(key, value string) { 162 195 raw := key + ":" + value 163 196 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ฤฑ๏ผฉ๏ฝ‰1lฤบฤพฤผแธทแธนlฬƒแธฝแธปล‚ล€ฦš๊‰โฑกษซษฌ๊žŽ๊ฌท๊ฌธ๊ฌนแถ…ษญศด๏ผฌ๏ฝŒ][nลƒล„วธวนล‡ลˆร‘รฑแน„แน…ล…ล†แน†แน‡แนŠแน‹แนˆแน‰NฬˆnฬˆฦษฒลŠล‹๊ž๊ž‘๊žค๊žฅแตฐแถ‡ษณศต๊ฌป๊ฌผะ˜ะธะŸะฟ๏ผฎ๏ฝŽ][kแธฐแธฑวจวฉฤถฤทแธฒแธณแธดแธตฦ˜ฦ™โฑฉโฑชแถ„๊€๊๊‚๊ƒ๊„๊…๊žข๊žฃ][sลšล›แนคแนฅลœลล ลกแนฆแนงแน แนกลžลŸแนขแนฃแนจแนฉศ˜ศ™Sฬฉsฬฉ๊žจ๊žฉโฑพศฟ๊Ÿ…ส‚แถŠแตด]?\b`, 25 + `(?i)\b[cฤ†ฤ‡ฤˆฤ‰ฤŒฤฤŠฤ‹ร‡รงแธˆแธ‰ศปศผ๊ž’๊ž“๊Ÿ„๊ž”ฦ‡ฦˆษ•][ร“รณร’รฒลŽลร”รดแปแป‘แป’แป“แป–แป—แป”แป•ว‘ว’ร–รถศชศซลล‘ร•รตแนŒแนแนŽแนศฌศญศฎศฏOอ˜oอ˜ศฐศฑร˜รธวพวฟวชวซวฌวญลŒลแน’แน“แนแน‘แปŽแปศŒศศŽศฦ ฦกแปšแป›แปœแปแป แปกแปžแปŸแปขแปฃแปŒแปแป˜แป™Oฬฉoฬฉร’ฬฉรฒฬฉร“ฬฉรณฬฉฦŸษต๊Š๊‹๊Œ๊โฑบ๏ผฏ๏ฝ0]{2}[nลƒล„วธวนล‡ลˆร‘รฑแน„แน…ล…ล†แน†แน‡แนŠแน‹แนˆแน‰NฬˆnฬˆฦษฒลŠล‹๊ž๊ž‘๊žค๊žฅแตฐแถ‡ษณศต๊ฌป๊ฌผะ˜ะธะŸะฟ๏ผฎ๏ฝŽ][sลšล›แนคแนฅลœลล ลกแนฆแนงแน แนกลžลŸแนขแนฃแนจแนฉศ˜ศ™Sฬฉsฬฉ๊žจ๊žฉโฑพศฟ๊Ÿ…ส‚แถŠแตด]?\b`, 26 + `(?i)\b[fแธžแธŸฦ‘ฦ’๊ž˜๊ž™แตฎแถ‚][aรรกร€ร ฤ‚ฤƒแบฎแบฏแบฐแบฑแบดแบตแบฒแบณร‚รขแบคแบฅแบฆแบงแบชแบซแบจแบฉววŽร…รฅวบวปร„รควžวŸรƒรฃศฆศงว วกฤ„ฤ…ฤ„ฬฤ…ฬฤ„ฬƒฤ…ฬƒฤ€ฤฤ€ฬ€ฤฬ€แบขแบฃศ€ศAฬ‹aฬ‹ศ‚ศƒแบ แบกแบถแบทแบฌแบญแธ€แธศบโฑฅ๊žบ๊žปแถแบš๏ผก๏ฝ@4][gวดวตฤžฤŸฤœฤวฆวงฤ ฤกGฬƒgฬƒฤขฤฃแธ แธกวควฅ๊ž ๊žกฦ“ษ แถƒ๊ฌถ๏ผง๏ฝ‡]{1,2}([ร“รณร’รฒลŽลร”รดแปแป‘แป’แป“แป–แป—แป”แป•ว‘ว’ร–รถศชศซลล‘ร•รตแนŒแนแนŽแนศฌศญศฎศฏOอ˜oอ˜ศฐศฑร˜รธวพวฟวชวซวฌวญลŒลแน’แน“แนแน‘แปŽแปศŒศศŽศฦ ฦกแปšแป›แปœแปแป แปกแปžแปŸแปขแปฃแปŒแปแป˜แป™Oฬฉoฬฉร’ฬฉรฒฬฉร“ฬฉรณฬฉฦŸษต๊Š๊‹๊Œ๊โฑบ๏ผฏ๏ฝ0e3ะ„ั”ะ•ะตร‰รฉรˆรจฤ”ฤ•รŠรชแบพแบฟแป€แปแป„แป…แป‚แปƒรŠฬ„รชฬ„รŠฬŒรชฬŒฤšฤ›ร‹รซแบผแบฝฤ–ฤ—ฤ–ฬฤ—ฬฤ–ฬƒฤ—ฬƒศจศฉแธœแธฤ˜ฤ™ฤ˜ฬฤ™ฬฤ˜ฬƒฤ™ฬƒฤ’ฤ“แธ–แธ—แธ”แธ•แบบแบปศ„ศ…Eฬ‹eฬ‹ศ†ศ‡แบธแบนแป†แป‡แธ˜แธ™แธšแธ›ษ†ษ‡Eฬฉeฬฉรˆฬฉรจฬฉร‰ฬฉรฉฬฉแถ’โฑธ๊ฌด๊ฌณ๏ผฅ๏ฝ…iรรญiฬ‡ฬรŒรฌiฬ‡ฬ€ฤฌฤญรŽรฎววรรฏแธฎแธฏฤจฤฉiฬ‡ฬƒฤฎฤฏฤฎฬฤฏฬ‡ฬฤฎฬƒฤฏฬ‡ฬƒฤชฤซฤชฬ€ฤซฬ€แปˆแป‰ศˆศ‰Iฬ‹iฬ‹ศŠศ‹แปŠแป‹๊žผ๊žฝแธฌแธญฦ—ษจแถ–ฤฐiIฤฑ๏ผฉ๏ฝ‰1lฤบฤพฤผแธทแธนlฬƒแธฝแธปล‚ล€ฦš๊‰โฑกษซษฌ๊žŽ๊ฌท๊ฌธ๊ฌนแถ…ษญศด๏ผฌ๏ฝŒ][tลคลฅแนชแนซลขลฃแนฌแนญศšศ›แนฐแนฑแนฎแนฏลฆลงศพโฑฆฦฌฦญฦฎสˆTฬˆแบ—แตตฦซศถ]{1,2}([rล”ล•ล˜ล™แน˜แน™ล–ล—ศศ‘ศ’ศ“แนšแน›แนœแนแนžแนŸRฬƒrฬƒษŒษ๊žฆ๊žงโฑคษฝแตฒแถ‰๊ญ‰][yรรฝแปฒแปณลถลทYฬŠแบ™ลธรฟแปธแปนแบŽแบศฒศณแปถแปทแปดแปตษŽษฦณฦดแปพแปฟ]|[rล”ล•ล˜ล™แน˜แน™ล–ล—ศศ‘ศ’ศ“แนšแน›แนœแนแนžแนŸRฬƒrฬƒษŒษ๊žฆ๊žงโฑคษฝแตฒแถ‰๊ญ‰][iรรญiฬ‡ฬรŒรฌiฬ‡ฬ€ฤฌฤญรŽรฎววรรฏแธฎแธฏฤจฤฉiฬ‡ฬƒฤฎฤฏฤฎฬฤฏฬ‡ฬฤฎฬƒฤฏฬ‡ฬƒฤชฤซฤชฬ€ฤซฬ€แปˆแป‰ศˆศ‰Iฬ‹iฬ‹ศŠศ‹แปŠแป‹๊žผ๊žฝแธฌแธญฦ—ษจแถ–ฤฐiIฤฑ๏ผฉ๏ฝ‰1lฤบฤพฤผแธทแธนlฬƒแธฝแธปล‚ล€ฦš๊‰โฑกษซษฌ๊žŽ๊ฌท๊ฌธ๊ฌนแถ…ษญศด๏ผฌ๏ฝŒ][e3ะ„ั”ะ•ะตร‰รฉรˆรจฤ”ฤ•รŠรชแบพแบฟแป€แปแป„แป…แป‚แปƒรŠฬ„รชฬ„รŠฬŒรชฬŒฤšฤ›ร‹รซแบผแบฝฤ–ฤ—ฤ–ฬฤ—ฬฤ–ฬƒฤ—ฬƒศจศฉแธœแธฤ˜ฤ™ฤ˜ฬฤ™ฬฤ˜ฬƒฤ™ฬƒฤ’ฤ“แธ–แธ—แธ”แธ•แบบแบปศ„ศ…Eฬ‹eฬ‹ศ†ศ‡แบธแบนแป†แป‡แธ˜แธ™แธšแธ›ษ†ษ‡Eฬฉeฬฉรˆฬฉรจฬฉร‰ฬฉรฉฬฉแถ’โฑธ๊ฌด๊ฌณ๏ผฅ๏ฝ…])?)?[sลšล›แนคแนฅลœลล ลกแนฆแนงแน แนกลžลŸแนขแนฃแนจแนฉศ˜ศ™Sฬฉsฬฉ๊žจ๊žฉโฑพศฟ๊Ÿ…ส‚แถŠแตด]?\b`, 27 + `(?i)\b[kแธฐแธฑวจวฉฤถฤทแธฒแธณแธดแธตฦ˜ฦ™โฑฉโฑชแถ„๊€๊๊‚๊ƒ๊„๊…๊žข๊žฃ][iรรญiฬ‡ฬรŒรฌiฬ‡ฬ€ฤฌฤญรŽรฎววรรฏแธฎแธฏฤจฤฉiฬ‡ฬƒฤฎฤฏฤฎฬฤฏฬ‡ฬฤฎฬƒฤฏฬ‡ฬƒฤชฤซฤชฬ€ฤซฬ€แปˆแป‰ศˆศ‰Iฬ‹iฬ‹ศŠศ‹แปŠแป‹๊žผ๊žฝแธฌแธญฦ—ษจแถ–ฤฐiIฤฑ๏ผฉ๏ฝ‰1lฤบฤพฤผแธทแธนlฬƒแธฝแธปล‚ล€ฦš๊‰โฑกษซษฌ๊žŽ๊ฌท๊ฌธ๊ฌนแถ…ษญศด๏ผฌ๏ฝŒyรรฝแปฒแปณลถลทYฬŠแบ™ลธรฟแปธแปนแบŽแบศฒศณแปถแปทแปดแปตษŽษฦณฦดแปพแปฟ][kแธฐแธฑวจวฉฤถฤทแธฒแธณแธดแธตฦ˜ฦ™โฑฉโฑชแถ„๊€๊๊‚๊ƒ๊„๊…๊žข๊žฃ][e3ะ„ั”ะ•ะตร‰รฉรˆรจฤ”ฤ•รŠรชแบพแบฟแป€แปแป„แป…แป‚แปƒรŠฬ„รชฬ„รŠฬŒรชฬŒฤšฤ›ร‹รซแบผแบฝฤ–ฤ—ฤ–ฬฤ—ฬฤ–ฬƒฤ—ฬƒศจศฉแธœแธฤ˜ฤ™ฤ˜ฬฤ™ฬฤ˜ฬƒฤ™ฬƒฤ’ฤ“แธ–แธ—แธ”แธ•แบบแบปศ„ศ…Eฬ‹eฬ‹ศ†ศ‡แบธแบนแป†แป‡แธ˜แธ™แธšแธ›ษ†ษ‡Eฬฉeฬฉรˆฬฉรจฬฉร‰ฬฉรฉฬฉแถ’โฑธ๊ฌด๊ฌณ๏ผฅ๏ฝ…]([rล”ล•ล˜ล™แน˜แน™ล–ล—ศศ‘ศ’ศ“แนšแน›แนœแนแนžแนŸRฬƒrฬƒษŒษ๊žฆ๊žงโฑคษฝแตฒแถ‰๊ญ‰][yรรฝแปฒแปณลถลทYฬŠแบ™ลธรฟแปธแปนแบŽแบศฒศณแปถแปทแปดแปตษŽษฦณฦดแปพแปฟ]|[rล”ล•ล˜ล™แน˜แน™ล–ล—ศศ‘ศ’ศ“แนšแน›แนœแนแนžแนŸRฬƒrฬƒษŒษ๊žฆ๊žงโฑคษฝแตฒแถ‰๊ญ‰][iรรญiฬ‡ฬรŒรฌiฬ‡ฬ€ฤฌฤญรŽรฎววรรฏแธฎแธฏฤจฤฉiฬ‡ฬƒฤฎฤฏฤฎฬฤฏฬ‡ฬฤฎฬƒฤฏฬ‡ฬƒฤชฤซฤชฬ€ฤซฬ€แปˆแป‰ศˆศ‰Iฬ‹iฬ‹ศŠศ‹แปŠแป‹๊žผ๊žฝแธฌแธญฦ—ษจแถ–ฤฐiIฤฑ๏ผฉ๏ฝ‰1lฤบฤพฤผแธทแธนlฬƒแธฝแธปล‚ล€ฦš๊‰โฑกษซษฌ๊žŽ๊ฌท๊ฌธ๊ฌนแถ…ษญศด๏ผฌ๏ฝŒ][e3ะ„ั”ะ•ะตร‰รฉรˆรจฤ”ฤ•รŠรชแบพแบฟแป€แปแป„แป…แป‚แปƒรŠฬ„รชฬ„รŠฬŒรชฬŒฤšฤ›ร‹รซแบผแบฝฤ–ฤ—ฤ–ฬฤ—ฬฤ–ฬƒฤ—ฬƒศจศฉแธœแธฤ˜ฤ™ฤ˜ฬฤ™ฬฤ˜ฬƒฤ™ฬƒฤ’ฤ“แธ–แธ—แธ”แธ•แบบแบปศ„ศ…Eฬ‹eฬ‹ศ†ศ‡แบธแบนแป†แป‡แธ˜แธ™แธšแธ›ษ†ษ‡Eฬฉeฬฉรˆฬฉรจฬฉร‰ฬฉรฉฬฉแถ’โฑธ๊ฌด๊ฌณ๏ผฅ๏ฝ…])?[sลšล›แนคแนฅลœลล ลกแนฆแนงแน แนกลžลŸแนขแนฃแนจแนฉศ˜ศ™Sฬฉsฬฉ๊žจ๊žฉโฑพศฟ๊Ÿ…ส‚แถŠแตด]*\b`, 28 + `(?i)\b[nลƒล„วธวนล‡ลˆร‘รฑแน„แน…ล…ล†แน†แน‡แนŠแน‹แนˆแน‰NฬˆnฬˆฦษฒลŠล‹๊ž๊ž‘๊žค๊žฅแตฐแถ‡ษณศต๊ฌป๊ฌผะ˜ะธะŸะฟ๏ผฎ๏ฝŽ][iรรญiฬ‡ฬรŒรฌiฬ‡ฬ€ฤฌฤญรŽรฎววรรฏแธฎแธฏฤจฤฉiฬ‡ฬƒฤฎฤฏฤฎฬฤฏฬ‡ฬฤฎฬƒฤฏฬ‡ฬƒฤชฤซฤชฬ€ฤซฬ€แปˆแป‰ศˆศ‰Iฬ‹iฬ‹ศŠศ‹แปŠแป‹๊žผ๊žฝแธฌแธญฦ—ษจแถ–ฤฐiIฤฑ๏ผฉ๏ฝ‰1lฤบฤพฤผแธทแธนlฬƒแธฝแธปล‚ล€ฦš๊‰โฑกษซษฌ๊žŽ๊ฌท๊ฌธ๊ฌนแถ…ษญศด๏ผฌ๏ฝŒoร“รณร’รฒลŽลร”รดแปแป‘แป’แป“แป–แป—แป”แป•ว‘ว’ร–รถศชศซลล‘ร•รตแนŒแนแนŽแนศฌศญศฎศฏOอ˜oอ˜ศฐศฑร˜รธวพวฟวชวซวฌวญลŒลแน’แน“แนแน‘แปŽแปศŒศศŽศฦ ฦกแปšแป›แปœแปแป แปกแปžแปŸแปขแปฃแปŒแปแป˜แป™Oฬฉoฬฉร’ฬฉรฒฬฉร“ฬฉรณฬฉฦŸษต๊Š๊‹๊Œ๊โฑบ๏ผฏ๏ฝะ†ั–a4รรกร€ร ฤ‚ฤƒแบฎแบฏแบฐแบฑแบดแบตแบฒแบณร‚รขแบคแบฅแบฆแบงแบชแบซแบจแบฉววŽร…รฅวบวปร„รควžวŸรƒรฃศฆศงว วกฤ„ฤ…ฤ„ฬฤ…ฬฤ„ฬƒฤ…ฬƒฤ€ฤฤ€ฬ€ฤฬ€แบขแบฃศ€ศAฬ‹aฬ‹ศ‚ศƒแบ แบกแบถแบทแบฌแบญแธ€แธศบโฑฅ๊žบ๊žปแถแบš๏ผก๏ฝ][gวดวตฤžฤŸฤœฤวฆวงฤ ฤกGฬƒgฬƒฤขฤฃแธ แธกวควฅ๊ž ๊žกฦ“ษ แถƒ๊ฌถ๏ผง๏ฝ‡q๊–๊—๊˜๊™ษ‹ส ]{2}(l[e3ะ„ั”ะ•ะตร‰รฉรˆรจฤ”ฤ•รŠรชแบพแบฟแป€แปแป„แป…แป‚แปƒรŠฬ„รชฬ„รŠฬŒรชฬŒฤšฤ›ร‹รซแบผแบฝฤ–ฤ—ฤ–ฬฤ—ฬฤ–ฬƒฤ—ฬƒศจศฉแธœแธฤ˜ฤ™ฤ˜ฬฤ™ฬฤ˜ฬƒฤ™ฬƒฤ’ฤ“แธ–แธ—แธ”แธ•แบบแบปศ„ศ…Eฬ‹eฬ‹ศ†ศ‡แบธแบนแป†แป‡แธ˜แธ™แธšแธ›ษ†ษ‡Eฬฉeฬฉรˆฬฉรจฬฉร‰ฬฉรฉฬฉแถ’โฑธ๊ฌด๊ฌณ๏ผฅ๏ฝ…]t|[e3ะ„ั”ะ•ะตร‰รฉรˆรจฤ”ฤ•รŠรชแบพแบฟแป€แปแป„แป…แป‚แปƒรŠฬ„รชฬ„รŠฬŒรชฬŒฤšฤ›ร‹รซแบผแบฝฤ–ฤ—ฤ–ฬฤ—ฬฤ–ฬƒฤ—ฬƒศจศฉแธœแธฤ˜ฤ™ฤ˜ฬฤ™ฬฤ˜ฬƒฤ™ฬƒฤ’ฤ“แธ–แธ—แธ”แธ•แบบแบปศ„ศ…Eฬ‹eฬ‹ศ†ศ‡แบธแบนแป†แป‡แธ˜แธ™แธšแธ›ษ†ษ‡Eฬฉeฬฉรˆฬฉรจฬฉร‰ฬฉรฉฬฉแถ’โฑธ๊ฌด๊ฌณ๏ผฅ๏ฝ…aรรกร€ร ฤ‚ฤƒแบฎแบฏแบฐแบฑแบดแบตแบฒแบณร‚รขแบคแบฅแบฆแบงแบชแบซแบจแบฉววŽร…รฅวบวปร„รควžวŸรƒรฃศฆศงว วกฤ„ฤ…ฤ„ฬฤ…ฬฤ„ฬƒฤ…ฬƒฤ€ฤฤ€ฬ€ฤฬ€แบขแบฃศ€ศAฬ‹aฬ‹ศ‚ศƒแบ แบกแบถแบทแบฌแบญแธ€แธศบโฑฅ๊žบ๊žปแถแบš๏ผก๏ฝ][rล”ล•ล˜ล™แน˜แน™ล–ล—ศศ‘ศ’ศ“แนšแน›แนœแนแนžแนŸRฬƒrฬƒษŒษ๊žฆ๊žงโฑคษฝแตฒแถ‰๊ญ‰]?|n[ร“รณร’รฒลŽลร”รดแปแป‘แป’แป“แป–แป—แป”แป•ว‘ว’ร–รถศชศซลล‘ร•รตแนŒแนแนŽแนศฌศญศฎศฏOอ˜oอ˜ศฐศฑร˜รธวพวฟวชวซวฌวญลŒลแน’แน“แนแน‘แปŽแปศŒศศŽศฦ ฦกแปšแป›แปœแปแป แปกแปžแปŸแปขแปฃแปŒแปแป˜แป™Oฬฉoฬฉร’ฬฉรฒฬฉร“ฬฉรณฬฉฦŸษต๊Š๊‹๊Œ๊โฑบ๏ผฏ๏ฝ0][gวดวตฤžฤŸฤœฤวฆวงฤ ฤกGฬƒgฬƒฤขฤฃแธ แธกวควฅ๊ž ๊žกฦ“ษ แถƒ๊ฌถ๏ผง๏ฝ‡q๊–๊—๊˜๊™ษ‹ส ]|[a4รรกร€ร ฤ‚ฤƒแบฎแบฏแบฐแบฑแบดแบตแบฒแบณร‚รขแบคแบฅแบฆแบงแบชแบซแบจแบฉววŽร…รฅวบวปร„รควžวŸรƒรฃศฆศงว วกฤ„ฤ…ฤ„ฬฤ…ฬฤ„ฬƒฤ…ฬƒฤ€ฤฤ€ฬ€ฤฬ€แบขแบฃศ€ศAฬ‹aฬ‹ศ‚ศƒแบ แบกแบถแบทแบฌแบญแธ€แธศบโฑฅ๊žบ๊žปแถแบš๏ผก๏ฝ]?)?[sลšล›แนคแนฅลœลล ลกแนฆแนงแน แนกลžลŸแนขแนฃแนจแนฉศ˜ศ™Sฬฉsฬฉ๊žจ๊žฉโฑพศฟ๊Ÿ…ส‚แถŠแตด]?\b`, 29 + `(?i)[nลƒล„วธวนล‡ลˆร‘รฑแน„แน…ล…ล†แน†แน‡แนŠแน‹แนˆแน‰NฬˆnฬˆฦษฒลŠล‹๊ž๊ž‘๊žค๊žฅแตฐแถ‡ษณศต๊ฌป๊ฌผะ˜ะธะŸะฟ๏ผฎ๏ฝŽ][iรรญiฬ‡ฬรŒรฌiฬ‡ฬ€ฤฌฤญรŽรฎววรรฏแธฎแธฏฤจฤฉiฬ‡ฬƒฤฎฤฏฤฎฬฤฏฬ‡ฬฤฎฬƒฤฏฬ‡ฬƒฤชฤซฤชฬ€ฤซฬ€แปˆแป‰ศˆศ‰Iฬ‹iฬ‹ศŠศ‹แปŠแป‹๊žผ๊žฝแธฌแธญฦ—ษจแถ–ฤฐiIฤฑ๏ผฉ๏ฝ‰1lฤบฤพฤผแธทแธนlฬƒแธฝแธปล‚ล€ฦš๊‰โฑกษซษฌ๊žŽ๊ฌท๊ฌธ๊ฌนแถ…ษญศด๏ผฌ๏ฝŒoร“รณร’รฒลŽลร”รดแปแป‘แป’แป“แป–แป—แป”แป•ว‘ว’ร–รถศชศซลล‘ร•รตแนŒแนแนŽแนศฌศญศฎศฏOอ˜oอ˜ศฐศฑร˜รธวพวฟวชวซวฌวญลŒลแน’แน“แนแน‘แปŽแปศŒศศŽศฦ ฦกแปšแป›แปœแปแป แปกแปžแปŸแปขแปฃแปŒแปแป˜แป™Oฬฉoฬฉร’ฬฉรฒฬฉร“ฬฉรณฬฉฦŸษต๊Š๊‹๊Œ๊โฑบ๏ผฏ๏ฝะ†ั–a4รรกร€ร ฤ‚ฤƒแบฎแบฏแบฐแบฑแบดแบตแบฒแบณร‚รขแบคแบฅแบฆแบงแบชแบซแบจแบฉววŽร…รฅวบวปร„รควžวŸรƒรฃศฆศงว วกฤ„ฤ…ฤ„ฬฤ…ฬฤ„ฬƒฤ…ฬƒฤ€ฤฤ€ฬ€ฤฬ€แบขแบฃศ€ศAฬ‹aฬ‹ศ‚ศƒแบ แบกแบถแบทแบฌแบญแธ€แธศบโฑฅ๊žบ๊žปแถแบš๏ผก๏ฝ][gวดวตฤžฤŸฤœฤวฆวงฤ ฤกGฬƒgฬƒฤขฤฃแธ แธกวควฅ๊ž ๊žกฦ“ษ แถƒ๊ฌถ๏ผง๏ฝ‡q๊–๊—๊˜๊™ษ‹ส ]{2}(l[e3ะ„ั”ะ•ะตร‰รฉรˆรจฤ”ฤ•รŠรชแบพแบฟแป€แปแป„แป…แป‚แปƒรŠฬ„รชฬ„รŠฬŒรชฬŒฤšฤ›ร‹รซแบผแบฝฤ–ฤ—ฤ–ฬฤ—ฬฤ–ฬƒฤ—ฬƒศจศฉแธœแธฤ˜ฤ™ฤ˜ฬฤ™ฬฤ˜ฬƒฤ™ฬƒฤ’ฤ“แธ–แธ—แธ”แธ•แบบแบปศ„ศ…Eฬ‹eฬ‹ศ†ศ‡แบธแบนแป†แป‡แธ˜แธ™แธšแธ›ษ†ษ‡Eฬฉeฬฉรˆฬฉรจฬฉร‰ฬฉรฉฬฉแถ’โฑธ๊ฌด๊ฌณ๏ผฅ๏ฝ…]t|[e3ะ„ั”ะ•ะตร‰รฉรˆรจฤ”ฤ•รŠรชแบพแบฟแป€แปแป„แป…แป‚แปƒรŠฬ„รชฬ„รŠฬŒรชฬŒฤšฤ›ร‹รซแบผแบฝฤ–ฤ—ฤ–ฬฤ—ฬฤ–ฬƒฤ—ฬƒศจศฉแธœแธฤ˜ฤ™ฤ˜ฬฤ™ฬฤ˜ฬƒฤ™ฬƒฤ’ฤ“แธ–แธ—แธ”แธ•แบบแบปศ„ศ…Eฬ‹eฬ‹ศ†ศ‡แบธแบนแป†แป‡แธ˜แธ™แธšแธ›ษ†ษ‡Eฬฉeฬฉรˆฬฉรจฬฉร‰ฬฉรฉฬฉแถ’โฑธ๊ฌด๊ฌณ๏ผฅ๏ฝ…][rล”ล•ล˜ล™แน˜แน™ล–ล—ศศ‘ศ’ศ“แนšแน›แนœแนแนžแนŸRฬƒrฬƒษŒษ๊žฆ๊žงโฑคษฝแตฒแถ‰๊ญ‰])[sลšล›แนคแนฅลœลล ลกแนฆแนงแน แนกลžลŸแนขแนฃแนจแนฉศ˜ศ™Sฬฉsฬฉ๊žจ๊žฉโฑพศฟ๊Ÿ…ส‚แถŠแตด]?`, 30 + `(?i)\b[tลคลฅแนชแนซลขลฃแนฌแนญศšศ›แนฐแนฑแนฎแนฏลฆลงศพโฑฆฦฌฦญฦฎสˆTฬˆแบ—แตตฦซศถ][rล”ล•ล˜ล™แน˜แน™ล–ล—ศศ‘ศ’ศ“แนšแน›แนœแนแนžแนŸRฬƒrฬƒษŒษ๊žฆ๊žงโฑคษฝแตฒแถ‰๊ญ‰][aรรกร€ร ฤ‚ฤƒแบฎแบฏแบฐแบฑแบดแบตแบฒแบณร‚รขแบคแบฅแบฆแบงแบชแบซแบจแบฉววŽร…รฅวบวปร„รควžวŸรƒรฃศฆศงว วกฤ„ฤ…ฤ„ฬฤ…ฬฤ„ฬƒฤ…ฬƒฤ€ฤฤ€ฬ€ฤฬ€แบขแบฃศ€ศAฬ‹aฬ‹ศ‚ศƒแบ แบกแบถแบทแบฌแบญแธ€แธศบโฑฅ๊žบ๊žปแถแบš๏ผก๏ฝ4]+[nลƒล„วธวนล‡ลˆร‘รฑแน„แน…ล…ล†แน†แน‡แนŠแน‹แนˆแน‰NฬˆnฬˆฦษฒลŠล‹๊ž๊ž‘๊žค๊žฅแตฐแถ‡ษณศต๊ฌป๊ฌผะ˜ะธะŸะฟ๏ผฎ๏ฝŽ]{1,2}([iรรญiฬ‡ฬรŒรฌiฬ‡ฬ€ฤฌฤญรŽรฎววรรฏแธฎแธฏฤจฤฉiฬ‡ฬƒฤฎฤฏฤฎฬฤฏฬ‡ฬฤฎฬƒฤฏฬ‡ฬƒฤชฤซฤชฬ€ฤซฬ€แปˆแป‰ศˆศ‰Iฬ‹iฬ‹ศŠศ‹แปŠแป‹๊žผ๊žฝแธฌแธญฦ—ษจแถ–ฤฐiIฤฑ๏ผฉ๏ฝ‰1lฤบฤพฤผแธทแธนlฬƒแธฝแธปล‚ล€ฦš๊‰โฑกษซษฌ๊žŽ๊ฌท๊ฌธ๊ฌนแถ…ษญศด๏ผฌ๏ฝŒ][e3ะ„ั”ะ•ะตร‰รฉรˆรจฤ”ฤ•รŠรชแบพแบฟแป€แปแป„แป…แป‚แปƒรŠฬ„รชฬ„รŠฬŒรชฬŒฤšฤ›ร‹รซแบผแบฝฤ–ฤ—ฤ–ฬฤ—ฬฤ–ฬƒฤ—ฬƒศจศฉแธœแธฤ˜ฤ™ฤ˜ฬฤ™ฬฤ˜ฬƒฤ™ฬƒฤ’ฤ“แธ–แธ—แธ”แธ•แบบแบปศ„ศ…Eฬ‹eฬ‹ศ†ศ‡แบธแบนแป†แป‡แธ˜แธ™แธšแธ›ษ†ษ‡Eฬฉeฬฉรˆฬฉรจฬฉร‰ฬฉรฉฬฉแถ’โฑธ๊ฌด๊ฌณ๏ผฅ๏ฝ…]|[yรรฝแปฒแปณลถลทYฬŠแบ™ลธรฟแปธแปนแบŽแบศฒศณแปถแปทแปดแปตษŽษฦณฦดแปพแปฟ]|[e3ะ„ั”ะ•ะตร‰รฉรˆรจฤ”ฤ•รŠรชแบพแบฟแป€แปแป„แป…แป‚แปƒรŠฬ„รชฬ„รŠฬŒรชฬŒฤšฤ›ร‹รซแบผแบฝฤ–ฤ—ฤ–ฬฤ—ฬฤ–ฬƒฤ—ฬƒศจศฉแธœแธฤ˜ฤ™ฤ˜ฬฤ™ฬฤ˜ฬƒฤ™ฬƒฤ’ฤ“แธ–แธ—แธ”แธ•แบบแบปศ„ศ…Eฬ‹eฬ‹ศ†ศ‡แบธแบนแป†แป‡แธ˜แธ™แธšแธ›ษ†ษ‡Eฬฉeฬฉรˆฬฉรจฬฉร‰ฬฉรฉฬฉแถ’โฑธ๊ฌด๊ฌณ๏ผฅ๏ฝ…][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 + }
+332 -40
appview/settings/settings.go
··· 1 1 package settings 2 2 3 3 import ( 4 + "context" 4 5 "database/sql" 5 6 "errors" 6 7 "fmt" 8 + "html" 7 9 "log" 10 + "log/slog" 8 11 "net/http" 9 12 "net/url" 13 + "slices" 10 14 "strings" 11 15 "time" 12 16 13 17 "github.com/go-chi/chi/v5" 14 18 "tangled.org/core/api/tangled" 19 + "tangled.org/core/appview/cloudflare" 15 20 "tangled.org/core/appview/config" 16 21 "tangled.org/core/appview/db" 17 22 "tangled.org/core/appview/email" ··· 19 24 "tangled.org/core/appview/models" 20 25 "tangled.org/core/appview/oauth" 21 26 "tangled.org/core/appview/pages" 27 + "tangled.org/core/appview/sites" 22 28 "tangled.org/core/tid" 23 29 24 30 comatproto "github.com/bluesky-social/indigo/api/atproto" 31 + atpclient "github.com/bluesky-social/indigo/atproto/client" 25 32 "github.com/bluesky-social/indigo/atproto/syntax" 26 33 lexutil "github.com/bluesky-social/indigo/lex/util" 27 34 "github.com/gliderlabs/ssh" ··· 29 36 ) 30 37 31 38 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 36 45 } 37 46 38 47 func (s *Settings) Router() http.Handler { ··· 64 73 r.Put("/", s.updateNotificationPreferences) 65 74 }) 66 75 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 + 67 92 return r 68 93 } 69 94 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 + 70 251 func (s *Settings) profileSettings(w http.ResponseWriter, r *http.Request) { 71 252 user := s.OAuth.GetMultiAccountUser(r) 72 253 ··· 75 256 log.Printf("failed to get users punchcard preferences: %s", err) 76 257 } 77 258 259 + isDeactivated := s.Config.Pds.IsTnglShUser(user.Pds()) && s.isAccountDeactivated(r.Context(), user.Did(), user.Pds()) 260 + 78 261 s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{ 79 262 LoggedInUser: user, 80 263 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", 81 268 }) 82 269 } 83 270 ··· 87 274 88 275 prefs, err := db.GetNotificationPreference(s.Db, did) 89 276 if err != nil { 277 + s.Logger.Error("failed to get notification preferences", "err", err) 90 - log.Printf("failed to get notification preferences: %s", err) 91 278 s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.") 92 279 return 93 280 } ··· 117 304 118 305 err := s.Db.UpdateNotificationPreferences(r.Context(), prefs) 119 306 if err != nil { 307 + s.Logger.Error("failed to update notification preferences", "err", err) 120 - log.Printf("failed to update notification preferences: %s", err) 121 308 s.Pages.Notice(w, "settings-notifications-error", "Unable to save notification preferences.") 122 309 return 123 310 } ··· 129 316 user := s.OAuth.GetMultiAccountUser(r) 130 317 pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Active.Did) 131 318 if err != nil { 319 + s.Logger.Error("keys settings", "err", err) 132 - log.Println(err) 133 320 } 134 321 135 322 s.Pages.UserKeysSettings(w, pages.UserKeysSettingsParams{ ··· 142 329 user := s.OAuth.GetMultiAccountUser(r) 143 330 emails, err := db.GetAllEmails(s.Db, user.Active.Did) 144 331 if err != nil { 332 + s.Logger.Error("emails settings", "err", err) 145 - log.Println(err) 146 333 } 147 334 148 335 s.Pages.UserEmailsSettings(w, pages.UserEmailsSettingsParams{ ··· 173 360 174 361 err := email.SendEmail(emailToSend) 175 362 if err != nil { 363 + s.Logger.Error("sending email", "err", err) 176 - log.Printf("sending email: %s", err) 177 364 s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext)) 178 365 return err 179 366 } ··· 185 372 switch r.Method { 186 373 case http.MethodGet: 187 374 s.Pages.Notice(w, "settings-emails", "Unimplemented.") 375 + s.Logger.Warn("emails: unimplemented method") 188 - log.Println("unimplemented") 189 376 return 190 377 case http.MethodPut: 191 378 did := s.OAuth.GetDid(r) ··· 200 387 // check if email already exists in database 201 388 existingEmail, err := db.GetEmail(s.Db, did, emAddr) 202 389 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) 204 391 s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 205 392 return 206 393 } ··· 220 407 // Begin transaction 221 408 tx, err := s.Db.Begin() 222 409 if err != nil { 410 + s.Logger.Error("failed to start transaction", "err", err) 223 - log.Printf("failed to start transaction: %s", err) 224 411 s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 225 412 return 226 413 } ··· 232 419 Verified: false, 233 420 VerificationCode: code, 234 421 }); err != nil { 422 + s.Logger.Error("adding email", "err", err) 235 - log.Printf("adding email: %s", err) 236 423 s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 237 424 return 238 425 } ··· 243 430 244 431 // Commit transaction 245 432 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) 247 434 s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 248 435 return 249 436 } ··· 258 445 // Begin transaction 259 446 tx, err := s.Db.Begin() 260 447 if err != nil { 448 + s.Logger.Error("failed to start transaction", "err", err) 261 - log.Printf("failed to start transaction: %s", err) 262 449 s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 263 450 return 264 451 } 265 452 defer tx.Rollback() 266 453 267 454 if err := db.DeleteEmail(tx, did, emailAddr); err != nil { 455 + s.Logger.Error("deleting email", "err", err) 268 - log.Printf("deleting email: %s", err) 269 456 s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 270 457 return 271 458 } 272 459 273 460 // Commit transaction 274 461 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) 276 463 s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 277 464 return 278 465 } ··· 302 489 303 490 valid, err := db.CheckValidVerificationCode(s.Db, did, emailAddr, code) 304 491 if err != nil { 492 + s.Logger.Error("checking email verification", "err", err) 305 - log.Printf("checking email verification: %s", err) 306 493 s.Pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.") 307 494 return 308 495 } ··· 314 501 315 502 // Mark email as verified in the database 316 503 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) 318 505 s.Pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.") 319 506 return 320 507 } ··· 343 530 if errors.Is(err, sql.ErrNoRows) { 344 531 s.Pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.") 345 532 } else { 533 + s.Logger.Error("checking for existing email", "err", err) 346 - log.Printf("checking for existing email: %s", err) 347 534 s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 348 535 } 349 536 return ··· 370 557 // Begin transaction 371 558 tx, err := s.Db.Begin() 372 559 if err != nil { 560 + s.Logger.Error("failed to start transaction", "err", err) 373 - log.Printf("failed to start transaction: %s", err) 374 561 s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 375 562 return 376 563 } ··· 378 565 379 566 // Update the verification code and last sent time 380 567 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) 382 569 s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 383 570 return 384 571 } ··· 390 577 391 578 // Commit transaction 392 579 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) 394 581 s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 395 582 return 396 583 } ··· 409 596 } 410 597 411 598 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) 413 600 s.Pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.") 414 601 return 415 602 } ··· 421 608 switch r.Method { 422 609 case http.MethodGet: 423 610 s.Pages.Notice(w, "settings-keys", "Unimplemented.") 611 + s.Logger.Warn("keys: unimplemented method") 424 - log.Println("unimplemented") 425 612 return 426 613 case http.MethodPut: 427 614 did := s.OAuth.GetDid(r) ··· 436 623 437 624 _, _, _, _, err = ssh.ParseAuthorizedKey([]byte(key)) 438 625 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.") 441 628 return 442 629 } 443 630 ··· 445 632 446 633 tx, err := s.Db.Begin() 447 634 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) 449 636 s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.") 450 637 return 451 638 } 452 639 defer tx.Rollback() 453 640 454 641 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) 456 643 s.Pages.Notice(w, "settings-keys", "Failed to add public key.") 457 644 return 458 645 } ··· 471 658 }) 472 659 // invalid record 473 660 if err != nil { 661 + s.Logger.Error("failed to create atproto record", "err", err) 474 - log.Printf("failed to create record: %s", err) 475 662 s.Pages.Notice(w, "settings-keys", "Failed to create record.") 476 663 return 477 664 } 478 665 666 + s.Logger.Info("created atproto record", "uri", resp.Uri) 479 - log.Println("created atproto record: ", resp.Uri) 480 667 481 668 err = tx.Commit() 482 669 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) 484 671 s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.") 485 672 return 486 673 } ··· 496 683 rkey := q.Get("rkey") 497 684 key := q.Get("key") 498 685 686 + s.Logger.Debug("deleting key", "name", name, "rkey", rkey, "key", key) 499 - log.Println(name) 500 - log.Println(rkey) 501 - log.Println(key) 502 687 503 688 client, err := s.OAuth.AuthorizedClient(r) 504 689 if err != nil { 690 + s.Logger.Error("failed to authorize client", "err", err) 505 - log.Printf("failed to authorize client: %s", err) 506 691 s.Pages.Notice(w, "settings-keys", "Failed to authorize client.") 507 692 return 508 693 } 509 694 510 695 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) 512 697 s.Pages.Notice(w, "settings-keys", "Failed to remove public key.") 513 698 return 514 699 } ··· 523 708 524 709 // invalid record 525 710 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.") 528 713 return 529 714 } 530 715 } 716 + s.Logger.Info("deleted key successfully", "name", name) 531 - log.Println("deleted successfully") 532 717 533 718 s.Pages.HxLocation(w, "/settings/keys") 534 719 return 535 720 } 536 721 } 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 + }
+49 -28
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" 17 18 "tangled.org/core/appview/config" 18 19 "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") 121 122 s.pages.Signup(w, pages.SignupParams{ 123 + CloudflareSiteKey: s.config.Cloudflare.Turnstile.SiteKey, 124 + EmailId: emailId, 122 - CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey, 123 125 }) 124 126 case http.MethodPost: 125 127 if s.cf == nil { ··· 234 236 235 237 // executeSignupTransaction performs the signup process transactionally with rollback 236 238 func (s *Signup) executeSignupTransaction(ctx context.Context, username, password, email, code string, w http.ResponseWriter) error { 239 + // var recordID string 237 - var recordID string 238 240 var did string 239 241 var emailAdded bool 240 242 ··· 244 246 s.l.Info("rolling back signup transaction", "username", username, "did", did) 245 247 246 248 // 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 - } 254 256 255 257 // Rollback PDS account 256 258 if did != "" { ··· 280 282 return err 281 283 } 282 284 285 + // XXX: we have a wildcard *.tngl.sh record now 283 286 // 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 - } 296 299 297 300 // step 3: add email to database 298 301 err = db.AddEmail(s.db, models.Email{ ··· 308 311 } 309 312 emailAdded = true 310 313 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 + 311 332 // if we get here, we've successfully created the account and added the email 312 333 success = true 313 334 ··· 337 358 return errors.New("captcha token is empty") 338 359 } 339 360 361 + if s.config.Cloudflare.Turnstile.SecretKey == "" { 340 - if s.config.Cloudflare.TurnstileSecretKey == "" { 341 362 return errors.New("turnstile secret key not configured") 342 363 } 343 364 344 365 data := url.Values{} 366 + data.Set("secret", s.config.Cloudflare.Turnstile.SecretKey) 345 - data.Set("secret", s.config.Cloudflare.TurnstileSecretKey) 346 367 data.Set("response", cfToken) 347 368 348 369 // 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) {
+48 -16
appview/state/git_http.go
··· 3 3 import ( 4 4 "fmt" 5 5 "io" 6 - "maps" 7 6 "net/http" 8 7 9 8 "github.com/bluesky-social/indigo/atproto/identity" ··· 11 10 "tangled.org/core/appview/models" 12 11 ) 13 12 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 + 14 39 func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 15 40 user := r.Context().Value("resolvedId").(identity.Identity) 16 41 repo := r.Context().Value("repo").(*models.Repo) ··· 20 45 scheme = "http" 21 46 } 22 47 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 + 23 59 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 - 26 61 } 27 62 28 63 func (s *State) UploadArchive(w http.ResponseWriter, r *http.Request) { ··· 39 74 } 40 75 41 76 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) 43 78 } 44 79 45 80 func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) { ··· 56 91 } 57 92 58 93 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) 60 95 } 61 96 62 97 func (s *State) ReceivePack(w http.ResponseWriter, r *http.Request) { ··· 73 108 } 74 109 75 110 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) 77 112 } 78 113 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) { 80 115 client := &http.Client{} 81 116 82 - // Create new request 83 117 proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body) 84 118 if err != nil { 85 119 http.Error(w, err.Error(), http.StatusInternalServerError) 86 120 return 87 121 } 88 122 123 + proxyReq.Header = r.Header.Clone() 89 - // Copy original headers 90 - proxyReq.Header = r.Header 91 124 92 125 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) 94 127 95 - // Execute request 96 128 resp, err := client.Do(proxyReq) 97 129 if err != nil { 98 130 http.Error(w, err.Error(), http.StatusInternalServerError) ··· 100 132 } 101 133 defer resp.Body.Close() 102 134 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) 105 139 106 - // Set response status code 107 140 w.WriteHeader(resp.StatusCode) 108 141 109 - // Copy response body 110 142 if _, err := io.Copy(w, resp.Body); err != nil { 111 143 http.Error(w, err.Error(), http.StatusInternalServerError) 112 144 return
+74 -7
appview/state/knotstream.go
··· 8 8 "slices" 9 9 "time" 10 10 11 + "tangled.org/core/appview/cloudflare" 11 12 "tangled.org/core/appview/notify" 12 13 13 14 "tangled.org/core/api/tangled" ··· 15 16 "tangled.org/core/appview/config" 16 17 "tangled.org/core/appview/db" 17 18 "tangled.org/core/appview/models" 19 + "tangled.org/core/appview/sites" 18 20 ec "tangled.org/core/eventconsumer" 19 21 "tangled.org/core/eventconsumer/cursor" 20 22 "tangled.org/core/log" ··· 27 29 "github.com/posthog/posthog-go" 28 30 ) 29 31 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) { 31 33 logger := log.FromContext(ctx) 32 34 logger = log.SubLogger(logger, "knotstream") 33 35 ··· 50 52 51 53 cfg := ec.ConsumerConfig{ 52 54 Sources: srcs, 55 + ProcessFunc: knotIngester(d, enforcer, posthog, notifier, c.Core.Dev, c, cfClient), 53 - ProcessFunc: knotIngester(d, enforcer, posthog, notifier, c.Core.Dev), 54 56 RetryInterval: c.Knotstream.RetryInterval, 55 57 MaxRetryInterval: c.Knotstream.MaxRetryInterval, 56 58 ConnectionTimeout: c.Knotstream.ConnectionTimeout, ··· 64 66 return ec.NewConsumer(cfg), nil 65 67 } 66 68 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 { 68 70 return func(ctx context.Context, source ec.Source, msg ec.Message) error { 69 71 switch msg.Nsid { 70 72 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) 72 74 case tangled.PipelineNSID: 73 75 return ingestPipeline(d, source, msg) 74 76 } ··· 77 79 } 78 80 } 79 81 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 { 81 83 logger := log.FromContext(ctx) 82 84 83 85 var record tangled.GitRefUpdate ··· 113 115 errWebhook = fmt.Errorf("failed to lookup repo for webhooks: %w", err) 114 116 } else if len(repos) == 1 { 115 117 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 + 131 136 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 + } 132 199 } 133 200 134 201 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" 4 5 "fmt" 5 6 "net/http" 6 7 "strings" 8 + "time" 7 9 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/xrpc" 8 12 "tangled.org/core/appview/oauth" 9 13 "tangled.org/core/appview/pages" 10 14 ) ··· 62 66 fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle), 63 67 ) 64 68 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 + } 65 98 } 66 99 67 100 if err := s.oauth.SetAuthReturn(w, r, returnURL, addAccount); err != nil {
+5 -1
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()) 93 97 94 98 var punchcard *models.Punchcard 95 99 if showPunchcard {
+9 -4
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) 37 38 38 39 userRouter := s.UserRouter(&middleware) 39 40 standardRouter := s.StandardRouter(&middleware) ··· 120 121 r.Handle("/static/*", s.pages.Static()) 121 122 122 123 r.Get("/", s.HomeOrTimeline) 124 + r.Get("/home", s.Home) 123 125 r.Get("/timeline", s.Timeline) 124 126 r.Get("/upgradeBanner", s.UpgradeBanner) 125 127 ··· 209 211 210 212 func (s *State) SettingsRouter() http.Handler { 211 213 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, 216 220 } 217 221 218 222 return settings.Router() ··· 315 319 s.enforcer, 316 320 log.SubLogger(s.logger, "repo"), 317 321 s.validator, 322 + s.cfClient, 318 323 ) 319 324 return repo.Router(mw) 320 325 }
+99 -86
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" 15 17 "tangled.org/core/appview/config" 16 18 "tangled.org/core/appview/db" 17 19 "tangled.org/core/appview/indexer" ··· 25 27 "tangled.org/core/appview/reporesolver" 26 28 "tangled.org/core/appview/validator" 27 29 xrpcclient "tangled.org/core/appview/xrpcclient" 30 + "tangled.org/core/consts" 28 31 "tangled.org/core/eventconsumer" 29 32 "tangled.org/core/idresolver" 30 33 "tangled.org/core/jetstream" ··· 38 41 atpclient "github.com/bluesky-social/indigo/atproto/client" 39 42 "github.com/bluesky-social/indigo/atproto/syntax" 40 43 lexutil "github.com/bluesky-social/indigo/lex/util" 44 + "github.com/bluesky-social/indigo/xrpc" 41 45 securejoin "github.com/cyphar/filepath-securejoin" 42 46 "github.com/go-chi/chi/v5" 43 47 "github.com/posthog/posthog-go" ··· 60 64 spindlestream *eventconsumer.Consumer 61 65 logger *slog.Logger 62 66 validator *validator.Validator 67 + cfClient *cloudflare.Client 63 68 } 64 69 65 70 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 113 118 tangled.PublicKeyNSID, 114 119 tangled.RepoArtifactNSID, 115 120 tangled.ActorProfileNSID, 121 + tangled.KnotMemberNSID, 116 122 tangled.SpindleMemberNSID, 117 123 tangled.SpindleNSID, 118 124 tangled.StringNSID, ··· 168 174 notifier := notify.NewMergedNotifier(notifiers) 169 175 notifier = notify.NewLoggingNotifier(notifier, tlog.SubLogger(logger, "notify")) 170 176 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) 172 187 if err != nil { 173 188 return nil, fmt.Errorf("failed to start knotstream consumer: %w", err) 174 189 } ··· 181 196 spindlestream.Start(ctx) 182 197 183 198 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, 200 216 } 201 217 218 + // fetch initial bluesky posts if configured 219 + go fetchBskyPosts(ctx, res, config, d, logger) 220 + 202 221 return state, nil 203 222 } 204 223 205 224 func (s *State) Close() error { 206 225 // other close up logic goes here 207 226 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)) 208 239 } 209 240 210 241 func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) { ··· 245 276 }) 246 277 } 247 278 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 - 292 279 func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 293 280 user := s.oauth.GetMultiAccountUser(r) 294 281 if user == nil { ··· 323 310 s.pages.UpgradeBanner(w, pages.UpgradeBannerParams{ 324 311 Registrations: regs, 325 312 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, 351 313 }) 352 314 } 353 315 ··· 670 632 671 633 return nil 672 634 } 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 + 410 426 if err := db.DeleteString( 411 427 s.Db, 412 428 orm.FilterEq("did", user.Active.Did),
avatar/src/index.js

This file has not been changed.

+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", 2 3 "image/bmp", 3 4 "image/cgm", 4 5 "image/g3fax", 5 6 "image/gif", 7 + "image/heif", 6 8 "image/ief", 7 9 "image/jp2", 10 + "image/jxl", 8 11 "image/jpeg", 9 12 "image/jpg", 10 13 "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 + }
+124 -22
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 + 281 383 # Knot self-hosting guide 282 384 283 385 So you want to run your own knot server? Great! Here are a few prerequisites: ··· 725 827 MY_ENV_VAR: "MY_ENV_VALUE" 726 828 ``` 727 829 830 + By default, the following environment variables are set: 728 - By default, the following environment variables set: 729 831 730 832 - `CI` - Always set to `true` to indicate a CI environment 731 833 - `TANGLED_PIPELINE_ID` - The AT URI of the current pipeline ··· 1236 1338 1237 1339 To set up a webhook for your repository: 1238 1340 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" 1242 1344 4. Configure your webhook: 1243 1345 - **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) 1245 1347 - **Events**: Select which events trigger the webhook (currently only push events) 1246 1348 - **Active**: Toggle whether the webhook is enabled 1247 1349 ··· 1470 1572 1471 1573 ## Running the appview 1472 1574 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: 1477 1577 1478 1578 ```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 - ``` 1492 1579 # OAuth JWKs should already be set up by the Nix devshell: 1493 1580 echo $TANGLED_OAUTH_CLIENT_SECRET 1494 1581 z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc ··· 1509 1596 1510 1597 # Run Redis in a new shell to store OAuth sessions 1511 1598 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 1512 1614 ``` 1513 1615 1514 1616 ## Running knots and spindles
+1 -7
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>
+206 -91
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 + 1 43 svg { 44 + width: 16px; 45 + height: 16px; 2 - width: 16px; 3 - height: 16px; 4 46 } 5 47 6 48 :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; 36 78 } 37 79 38 80 @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 - } 70 112 } 71 113 72 114 /* 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 */
+22 -7
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"/> 41 54 42 55 </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"> 44 57 $for(include-before)$ 45 58 $include-before$ 46 59 $endfor$ 47 60 48 61 $if(toc)$ 49 62 <!-- 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"> 51 64 <button 52 65 type="button" 53 66 popovertarget="mobile-toc-popover" ··· 62 75 <div 63 76 id="mobile-toc-popover" 64 77 popover 78 + data-pagefind-ignore 65 79 class="mobile-toc-popover 66 80 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 68 82 px-6 py-4 fixed inset-x-0 top-0 w-fit max-w-4/5 m-0" 69 83 > 70 84 <div class="flex flex-col min-h-full"> ··· 89 103 <nav 90 104 id="$idprefix$TOC" 91 105 role="doc-toc" 106 + data-pagefind-ignore 92 107 class="hidden md:flex md:flex-col gap-4 fixed left-0 top-0 w-80 h-screen 93 108 bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 94 109 p-4 z-50 overflow-y-auto">
+55 -3
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 + }, 19 40 "flake-compat": { 20 41 "flake": false, 21 42 "locked": { ··· 148 169 "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 149 170 } 150 171 }, 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 + }, 151 184 "nixpkgs": { 152 185 "locked": { 186 + "lastModified": 1771848320, 187 + "narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=", 153 - "lastModified": 1766070988, 154 - "narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=", 155 188 "owner": "nixos", 156 189 "repo": "nixpkgs", 190 + "rev": "2fc6539b481e1d2569f25f8799236694180c0993", 157 - "rev": "c6245e83d836d0433170a16eb185cefe0572f8b8", 158 191 "type": "github" 159 192 }, 160 193 "original": { ··· 167 200 "root": { 168 201 "inputs": { 169 202 "actor-typeahead-src": "actor-typeahead-src", 203 + "fenix": "fenix", 170 204 "flake-compat": "flake-compat", 171 205 "gomod2nix": "gomod2nix", 172 206 "htmx-src": "htmx-src", ··· 175 209 "indigo": "indigo", 176 210 "inter-fonts-src": "inter-fonts-src", 177 211 "lucide-src": "lucide-src", 212 + "mermaid-src": "mermaid-src", 178 213 "nixpkgs": "nixpkgs", 179 214 "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" 180 232 } 181 233 }, 182 234 "sqlite-lib-src": {
+46 -3
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 + }; 6 10 gomod2nix = { 7 11 url = "github:nix-community/gomod2nix"; 8 12 inputs.nixpkgs.follows = "nixpkgs"; ··· 37 41 url = "git+https://tangled.org/@jakelazaroff.com/actor-typeahead"; 38 42 flake = false; 39 43 }; 44 + mermaid-src = { 45 + url = "https://cdn.jsdelivr.net/npm/mermaid@11.12.3/dist/mermaid.min.js"; 46 + flake = false; 47 + }; 40 48 ibm-plex-mono-src = { 41 49 url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip"; 42 50 flake = false; ··· 50 58 outputs = { 51 59 self, 52 60 nixpkgs, 61 + fenix, 53 62 gomod2nix, 54 63 indigo, 55 64 htmx-src, ··· 59 68 sqlite-lib-src, 60 69 ibm-plex-mono-src, 61 70 actor-typeahead-src, 71 + mermaid-src, 62 72 ... 63 73 }: let 64 74 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; ··· 85 95 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 86 96 goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;}; 87 97 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; 89 99 }; 90 100 appview = self.callPackage ./nix/pkgs/appview.nix {}; 91 101 docs = self.callPackage ./nix/pkgs/docs.nix { 92 102 inherit inter-fonts-src ibm-plex-mono-src lucide-src; 103 + inherit (pkgs) pagefind; 93 104 }; 94 105 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 95 106 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; ··· 180 191 pkgs.tailwindcss 181 192 pkgs.nixos-shell 182 193 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 + ]) 183 204 pkgs.coreutils # for those of us who are on systems that use busybox (alpine) 184 205 packages'.lexgen 185 206 packages'.treefmt-wrapper 186 207 ]; 187 208 shellHook = '' 188 209 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 189 212 # 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 191 214 export TANGLED_OAUTH_CLIENT_KID="$(date +%s)" 192 215 export TANGLED_OAUTH_CLIENT_SECRET="$(${packages'.goat}/bin/goat key generate -t P-256 | grep -A1 "Secret Key" | tail -n1 | awk '{print $1}')" 193 216 ''; ··· 218 241 type = "app"; 219 242 program = toString (pkgs.writeShellScript "watch-appview" '' 220 243 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 222 246 ${air-watcher "appview" ""}/bin/run 223 247 ''); 224 248 }; ··· 230 254 type = "app"; 231 255 program = ''${air-watcher "spindle" ""}/bin/run''; 232 256 }; 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 + }; 233 266 watch-tailwind = { 234 267 type = "app"; 235 268 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 + ''); 236 279 }; 237 280 vm = let 238 281 guestSystem =
+21 -1
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 10 13 github.com/blevesearch/bleve/v2 v2.5.3 11 14 github.com/bluekeyes/go-gitdiff v0.8.1 12 15 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e ··· 15 18 github.com/carlmjohnson/versioninfo v0.22.5 16 19 github.com/casbin/casbin/v2 v2.103.0 17 20 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 19 22 github.com/cyphar/filepath-securejoin v0.4.1 20 23 github.com/dgraph-io/ristretto v0.2.0 21 24 github.com/docker/docker v28.2.2+incompatible ··· 48 51 github.com/yuin/goldmark-emoji v1.0.6 49 52 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 50 53 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 54 + go.abhg.dev/goldmark/mermaid v0.6.0 51 55 golang.org/x/crypto v0.40.0 52 56 golang.org/x/image v0.31.0 53 57 golang.org/x/net v0.42.0 ··· 57 61 58 62 require ( 59 63 dario.cat/mergo v1.0.1 // indirect 64 + github.com/BurntSushi/toml v0.3.1 // indirect 60 65 github.com/Microsoft/go-winio v0.6.2 // indirect 61 66 github.com/ProtonMail/go-crypto v1.3.0 // indirect 62 67 github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect 68 + github.com/adrg/frontmatter v0.2.0 // indirect 63 69 github.com/alecthomas/repr v0.5.2 // indirect 64 70 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 65 80 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 66 81 github.com/aymerick/douceur v0.2.0 // indirect 67 82 github.com/beorn7/perks v1.0.1 // indirect ··· 185 200 github.com/ryanuber/go-glob v1.0.0 // indirect 186 201 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 187 202 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 188 207 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 189 208 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 190 209 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect ··· 214 233 gopkg.in/fsnotify.v1 v1.4.7 // indirect 215 234 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 216 235 gopkg.in/warnings.v0 v0.1.2 // indirect 236 + gopkg.in/yaml.v2 v2.4.0 // indirect 217 237 gotest.tools/v3 v3.5.2 // indirect 218 238 lukechampine.com/blake3 v1.4.1 // indirect 219 239 )
+57 -2
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= 7 8 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 8 9 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 9 10 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= ··· 11 12 github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 12 13 github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg= 13 14 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= 14 17 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 15 18 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 16 19 github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= ··· 22 25 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 23 26 github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk= 24 27 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= 25 52 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 26 53 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 27 54 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= ··· 105 132 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 106 133 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 107 134 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= 108 141 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 109 142 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 110 143 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 111 144 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d h1:IiIprFGH6SqstblP0Y9NIo3eaUJGkI/YDOFVSL64Uq4= 112 145 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= 115 148 github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= 116 149 github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= 117 150 github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= ··· 175 208 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 176 209 github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 177 210 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= 178 213 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 179 214 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 180 215 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= ··· 189 224 github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 190 225 github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 191 226 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= 192 233 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 193 234 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 194 235 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= ··· 482 523 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 483 524 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 484 525 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= 485 537 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 486 538 github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= 487 539 github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= ··· 516 568 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 517 569 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 518 570 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= 519 573 go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= 520 574 go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= 521 575 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= ··· 719 773 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 720 774 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 721 775 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 776 + gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 722 777 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 723 778 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 724 779 gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+91 -27
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 + 218 223 /* Base callout */ 219 224 details[data-callout] { 220 225 @apply border-l-4 pl-3 py-2 text-gray-800 dark:text-gray-200 my-4; ··· 272 277 details[data-callout] > summary::-webkit-details-marker { 273 278 display: none; 274 279 } 275 - 276 280 } 277 281 @layer utilities { 278 282 .error { ··· 281 285 .success { 282 286 @apply py-1 text-gray-900 dark:text-gray-100; 283 287 } 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 - } 285 327 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 + } 286 336 } 287 337 288 338 /* Background */ ··· 321 371 /* LineHighlight */ 322 372 .chroma .hl { 323 373 @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; 324 386 } 325 387 326 388 /* LineNumbersTable */ ··· 959 1021 } 960 1022 961 1023 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; 970 1032 } 971 1033 972 1034 actor-typeahead::part(handle) { 1035 + color: #111827; 973 - color: #111827; 974 1036 } 975 1037 976 1038 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); 978 1042 } 979 1043 980 1044 @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 - } 988 1052 1053 + actor-typeahead::part(handle) { 1054 + color: #f9fafb; 1055 + } 989 - actor-typeahead::part(handle) { 990 - color: #f9fafb; 991 - } 992 1056 }
+24 -34
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" 12 11 "github.com/go-chi/chi/v5" 13 12 "tangled.org/core/knotserver/git/service" 14 13 ) 15 14 16 15 func (h *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 - did := chi.URLParam(r, "did") 18 16 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) 30 21 return 31 22 } 32 23 ··· 57 48 } 58 49 59 50 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) 66 55 return 67 56 } 68 57 ··· 104 93 } 105 94 106 95 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) 113 100 return 114 101 } 115 102 ··· 153 140 } 154 141 155 142 func (h *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 156 - did := chi.URLParam(r, "did") 157 143 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 - 165 144 h.RejectPush(w, r, name) 166 145 } 167 146 ··· 190 169 fmt.Fprintf(w, " Try:\ngit remote set-url --push origin git@%s:%s/%s\n\n... and push again.", hostname, ownerHandle, unqualifiedRepoName) 191 170 } 192 171 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 193 183 } 194 184 195 185 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" 8 9 "strings" 9 10 11 + securejoin "github.com/cyphar/filepath-securejoin" 10 12 "github.com/go-chi/chi/v5" 11 13 "tangled.org/core/idresolver" 12 14 "tangled.org/core/jetstream" ··· 81 83 82 84 r.Route("/{did}", func(r chi.Router) { 83 85 r.Use(h.resolveDidRedirect) 86 + r.Use(h.resolveRepo) 84 87 r.Route("/{name}", func(r chi.Router) { 85 88 // routes for git operations 86 89 r.Get("/info/refs", h.InfoRefs) ··· 138 141 suffix := strings.TrimPrefix(r.URL.Path, "/"+didOrHandle) 139 142 newPath := fmt.Sprintf("/%s/%s?%s", id.DID.String(), suffix, r.URL.RawQuery) 140 143 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)) 141 181 }) 142 182 } 143 183
+9 -1
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" { 78 80 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" 79 87 } 80 88 81 89 if raw {
+54 -3
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=" 35 71 [mod."github.com/aymanbagabas/go-osc52/v2"] 36 72 version = "v2.0.1" 37 73 hash = "sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg=" ··· 147 183 [mod."github.com/cloudflare/circl"] 148 184 version = "v1.6.2-0.20250618153321-aa837fd1539d" 149 185 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=" 153 189 [mod."github.com/containerd/errdefs"] 154 190 version = "v1.0.0" 155 191 hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI=" ··· 509 545 [mod."github.com/stretchr/testify"] 510 546 version = "v1.10.0" 511 547 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=" 512 560 [mod."github.com/urfave/cli/v3"] 513 561 version = "v3.3.3" 514 562 hash = "sha256-FdPiu7koY1qBinkfca4A05zCrX+Vu4eRz8wlRDZJyGg=" ··· 545 593 [mod."gitlab.com/yawning/tuplehash"] 546 594 version = "v0.0.0-20230713102510-df83abbf9a02" 547 595 hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato=" 596 + [mod."go.abhg.dev/goldmark/mermaid"] 597 + version = "v0.6.0" 598 + hash = "sha256-JmjaCfzJU/M/R0TnXSzNwBaHmoLLooiXwQJeVRbZ3AQ=" 548 599 [mod."go.etcd.io/bbolt"] 549 600 version = "v1.4.0" 550 601 hash = "sha256-nR/YGQjwz6ue99IFbgw/01Pl8PhoOjpKiwVy5sJxlps="
+2 -1
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 24 25 cp -rf ${lucide-src}/*.svg icons/ 25 26 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 26 27 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
+4
nix/pkgs/docs.nix
··· 1 1 { 2 2 pandoc, 3 + pagefind, 3 4 tailwindcss, 4 5 runCommandLocal, 5 6 inter-fonts-src, ··· 59 60 60 61 # styles 61 62 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 62 66 ''
+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
+83 -70
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")], 75 88 };

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!

eti.tf submitted #1
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!