A vibe coded tangled fork which supports pijul.
1package pages
2
3import (
4 "bytes"
5 "context"
6 "crypto/hmac"
7 "crypto/sha256"
8 "encoding/hex"
9 "errors"
10 "fmt"
11 "html"
12 "html/template"
13 "log"
14 "math"
15 "math/rand"
16 "net/url"
17 "path/filepath"
18 "reflect"
19 "strings"
20 "time"
21
22 "github.com/alecthomas/chroma/v2"
23 chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
24 "github.com/alecthomas/chroma/v2/lexers"
25 "github.com/alecthomas/chroma/v2/styles"
26 "github.com/dustin/go-humanize"
27 "github.com/go-enry/go-enry/v2"
28 "github.com/yuin/goldmark"
29 emoji "github.com/yuin/goldmark-emoji"
30 "tangled.org/core/appview/db"
31 "tangled.org/core/appview/models"
32 "tangled.org/core/appview/oauth"
33 "tangled.org/core/appview/pages/markup"
34 "tangled.org/core/appview/pages/markup/sanitizer"
35 "tangled.org/core/crypto"
36)
37
38type tab map[string]string
39
40func (p *Pages) funcMap() template.FuncMap {
41 return template.FuncMap{
42 "split": func(s string) []string {
43 return strings.Split(s, "\n")
44 },
45 "trimPrefix": func(s, prefix string) string {
46 return strings.TrimPrefix(s, prefix)
47 },
48 "join": func(elems []string, sep string) string {
49 return strings.Join(elems, sep)
50 },
51 "contains": func(s string, target string) bool {
52 return strings.Contains(s, target)
53 },
54 "stripPort": func(hostname string) string {
55 if strings.Contains(hostname, ":") {
56 return strings.Split(hostname, ":")[0]
57 }
58 return hostname
59 },
60 "mapContains": func(m any, key any) bool {
61 mapValue := reflect.ValueOf(m)
62 if mapValue.Kind() != reflect.Map {
63 return false
64 }
65 keyValue := reflect.ValueOf(key)
66 return mapValue.MapIndex(keyValue).IsValid()
67 },
68 "resolve": func(s string) string {
69 identity, err := p.resolver.ResolveIdent(context.Background(), s)
70
71 if err != nil {
72 return s
73 }
74
75 if identity.Handle.IsInvalidHandle() {
76 return "handle.invalid"
77 }
78
79 return identity.Handle.String()
80 },
81 "ownerSlashRepo": func(repo *models.Repo) string {
82 ownerId, err := p.resolver.ResolveIdent(context.Background(), repo.Did)
83 if err != nil {
84 return repo.DidSlashRepo()
85 }
86 handle := ownerId.Handle
87 if handle != "" && !handle.IsInvalidHandle() {
88 return string(handle) + "/" + repo.Name
89 }
90 return repo.DidSlashRepo()
91 },
92 "truncateAt30": func(s string) string {
93 if len(s) <= 30 {
94 return s
95 }
96 return s[:30] + "…"
97 },
98 "splitOn": func(s, sep string) []string {
99 return strings.Split(s, sep)
100 },
101 "string": func(v any) string {
102 return fmt.Sprint(v)
103 },
104 "int64": func(a int) int64 {
105 return int64(a)
106 },
107 "add": func(a, b int) int {
108 return a + b
109 },
110 "now": func() time.Time {
111 return time.Now()
112 },
113 // the absolute state of go templates
114 "add64": func(a, b int64) int64 {
115 return a + b
116 },
117 "sub": func(a, b int) int {
118 return a - b
119 },
120 "mul": func(a, b int) int {
121 return a * b
122 },
123 "div": func(a, b int) int {
124 return a / b
125 },
126 "mod": func(a, b int) int {
127 return a % b
128 },
129 "randInt": func(bound int) int {
130 return rand.Intn(bound)
131 },
132 "f64": func(a int) float64 {
133 return float64(a)
134 },
135 "addf64": func(a, b float64) float64 {
136 return a + b
137 },
138 "subf64": func(a, b float64) float64 {
139 return a - b
140 },
141 "mulf64": func(a, b float64) float64 {
142 return a * b
143 },
144 "divf64": func(a, b float64) float64 {
145 if b == 0 {
146 return 0
147 }
148 return a / b
149 },
150 "negf64": func(a float64) float64 {
151 return -a
152 },
153 "cond": func(cond any, a, b string) string {
154 if cond == nil {
155 return b
156 }
157
158 if boolean, ok := cond.(bool); boolean && ok {
159 return a
160 }
161
162 return b
163 },
164 "assoc": func(values ...string) ([][]string, error) {
165 if len(values)%2 != 0 {
166 return nil, fmt.Errorf("invalid assoc call, must have an even number of arguments")
167 }
168 pairs := make([][]string, 0)
169 for i := 0; i < len(values); i += 2 {
170 pairs = append(pairs, []string{values[i], values[i+1]})
171 }
172 return pairs, nil
173 },
174 "append": func(s []any, values ...any) []any {
175 s = append(s, values...)
176 return s
177 },
178 "commaFmt": humanize.Comma,
179 "relTimeFmt": humanize.Time,
180 "shortRelTimeFmt": func(t time.Time) string {
181 return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
182 {D: time.Second, Format: "now", DivBy: time.Second},
183 {D: 2 * time.Second, Format: "1s %s", DivBy: 1},
184 {D: time.Minute, Format: "%ds %s", DivBy: time.Second},
185 {D: 2 * time.Minute, Format: "1min %s", DivBy: 1},
186 {D: time.Hour, Format: "%dmin %s", DivBy: time.Minute},
187 {D: 2 * time.Hour, Format: "1hr %s", DivBy: 1},
188 {D: humanize.Day, Format: "%dhrs %s", DivBy: time.Hour},
189 {D: 2 * humanize.Day, Format: "1d %s", DivBy: 1},
190 {D: 20 * humanize.Day, Format: "%dd %s", DivBy: humanize.Day},
191 {D: 8 * humanize.Week, Format: "%dw %s", DivBy: humanize.Week},
192 {D: humanize.Year, Format: "%dmo %s", DivBy: humanize.Month},
193 {D: 18 * humanize.Month, Format: "1y %s", DivBy: 1},
194 {D: 2 * humanize.Year, Format: "2y %s", DivBy: 1},
195 {D: humanize.LongTime, Format: "%dy %s", DivBy: humanize.Year},
196 {D: math.MaxInt64, Format: "a long while %s", DivBy: 1},
197 })
198 },
199 "longTimeFmt": func(t time.Time) string {
200 return t.Format("Jan 2, 2006, 3:04 PM MST")
201 },
202 "iso8601DateTimeFmt": func(t time.Time) string {
203 return t.Format("2006-01-02T15:04:05-07:00")
204 },
205 "iso8601DurationFmt": func(duration time.Duration) string {
206 days := int64(duration.Hours() / 24)
207 hours := int64(math.Mod(duration.Hours(), 24))
208 minutes := int64(math.Mod(duration.Minutes(), 60))
209 seconds := int64(math.Mod(duration.Seconds(), 60))
210 return fmt.Sprintf("P%dD%dH%dM%dS", days, hours, minutes, seconds)
211 },
212 "durationFmt": func(duration time.Duration) string {
213 return durationFmt(duration, [4]string{"d", "hr", "min", "s"})
214 },
215 "longDurationFmt": func(duration time.Duration) string {
216 return durationFmt(duration, [4]string{"days", "hours", "minutes", "seconds"})
217 },
218 "byteFmt": humanize.Bytes,
219 "length": func(slice any) int {
220 v := reflect.ValueOf(slice)
221 if v.Kind() == reflect.Slice || v.Kind() == reflect.Array {
222 return v.Len()
223 }
224 return 0
225 },
226 "splitN": func(s, sep string, n int) []string {
227 return strings.SplitN(s, sep, n)
228 },
229 "escapeHtml": func(s string) template.HTML {
230 if s == "" {
231 return template.HTML("<br>")
232 }
233 return template.HTML(s)
234 },
235 "unescapeHtml": func(s string) string {
236 return html.UnescapeString(s)
237 },
238 "nl2br": func(text string) template.HTML {
239 return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "<br>"))
240 },
241 "unwrapText": func(text string) string {
242 paragraphs := strings.Split(text, "\n\n")
243
244 for i, p := range paragraphs {
245 lines := strings.Split(p, "\n")
246 paragraphs[i] = strings.Join(lines, " ")
247 }
248
249 return strings.Join(paragraphs, "\n\n")
250 },
251 "sequence": func(n int) []struct{} {
252 return make([]struct{}, n)
253 },
254 // take atmost N items from this slice
255 "take": func(slice any, n int) any {
256 v := reflect.ValueOf(slice)
257 if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
258 return nil
259 }
260 if v.Len() == 0 {
261 return nil
262 }
263 return v.Slice(0, min(n, v.Len())).Interface()
264 },
265 "markdown": func(text string) template.HTML {
266 p.rctx.RendererType = markup.RendererTypeDefault
267 htmlString := p.rctx.RenderMarkdown(text)
268 sanitized := sanitizer.SanitizeDefault(htmlString)
269 return template.HTML(sanitized)
270 },
271 "description": func(text string) template.HTML {
272 p.rctx.RendererType = markup.RendererTypeDefault
273 htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New(
274 goldmark.WithExtensions(
275 emoji.Emoji,
276 ),
277 ))
278 sanitized := sanitizer.SanitizeDescription(htmlString)
279 return template.HTML(sanitized)
280 },
281 "readme": func(text string) template.HTML {
282 p.rctx.RendererType = markup.RendererTypeRepoMarkdown
283 htmlString := p.rctx.RenderMarkdown(text)
284 sanitized := sanitizer.SanitizeDefault(htmlString)
285 return template.HTML(sanitized)
286 },
287 "code": func(content, path string) string {
288 var style *chroma.Style = styles.Get("catpuccin-latte")
289 formatter := chromahtml.New(
290 chromahtml.InlineCode(false),
291 chromahtml.WithLineNumbers(true),
292 chromahtml.WithLinkableLineNumbers(true, "L"),
293 chromahtml.Standalone(false),
294 chromahtml.WithClasses(true),
295 )
296
297 lexer := lexers.Get(filepath.Base(path))
298 if lexer == nil {
299 lexer = lexers.Fallback
300 }
301
302 iterator, err := lexer.Tokenise(nil, content)
303 if err != nil {
304 p.logger.Error("chroma tokenize", "err", "err")
305 return ""
306 }
307
308 var code bytes.Buffer
309 err = formatter.Format(&code, style, iterator)
310 if err != nil {
311 p.logger.Error("chroma format", "err", "err")
312 return ""
313 }
314
315 return code.String()
316 },
317 "trimUriScheme": func(text string) string {
318 text = strings.TrimPrefix(text, "https://")
319 text = strings.TrimPrefix(text, "http://")
320 return text
321 },
322 "isNil": func(t any) bool {
323 // returns false for other "zero" values
324 return t == nil
325 },
326 "list": func(args ...any) []any {
327 return args
328 },
329 "dict": func(values ...any) (map[string]any, error) {
330 if len(values)%2 != 0 {
331 return nil, errors.New("invalid dict call")
332 }
333 dict := make(map[string]any, len(values)/2)
334 for i := 0; i < len(values); i += 2 {
335 key, ok := values[i].(string)
336 if !ok {
337 return nil, errors.New("dict keys must be strings")
338 }
339 dict[key] = values[i+1]
340 }
341 return dict, nil
342 },
343 "queryParams": func(params ...any) (url.Values, error) {
344 if len(params)%2 != 0 {
345 return nil, errors.New("invalid queryParams call")
346 }
347 vals := make(url.Values, len(params)/2)
348 for i := 0; i < len(params); i += 2 {
349 key, ok := params[i].(string)
350 if !ok {
351 return nil, errors.New("queryParams keys must be strings")
352 }
353 v, ok := params[i+1].(string)
354 if !ok {
355 return nil, errors.New("queryParams values must be strings")
356 }
357 vals.Add(key, v)
358 }
359 return vals, nil
360 },
361 "deref": func(v any) any {
362 val := reflect.ValueOf(v)
363 if val.Kind() == reflect.Pointer && !val.IsNil() {
364 return val.Elem().Interface()
365 }
366 return nil
367 },
368 "i": func(name string, classes ...string) template.HTML {
369 data, err := p.icon(name, classes)
370 if err != nil {
371 log.Printf("icon %s does not exist", name)
372 data, _ = p.icon("airplay", classes)
373 }
374 return template.HTML(data)
375 },
376 "cssContentHash": p.CssContentHash,
377 "pathEscape": func(s string) string {
378 return url.PathEscape(s)
379 },
380 "pathUnescape": func(s string) string {
381 u, _ := url.PathUnescape(s)
382 return u
383 },
384 "safeUrl": func(s string) template.URL {
385 return template.URL(s)
386 },
387 "tinyAvatar": func(handle string) string {
388 return p.AvatarUrl(handle, "tiny")
389 },
390 "fullAvatar": func(handle string) string {
391 return p.AvatarUrl(handle, "")
392 },
393 "placeholderAvatar": func(size string) template.HTML {
394 sizeClass := "size-6"
395 iconSize := "size-4"
396 if size == "tiny" {
397 sizeClass = "size-6"
398 iconSize = "size-4"
399 } else if size == "small" {
400 sizeClass = "size-8"
401 iconSize = "size-5"
402 } else {
403 sizeClass = "size-12"
404 iconSize = "size-8"
405 }
406 icon, _ := p.icon("user-round", []string{iconSize, "text-gray-400", "dark:text-gray-500"})
407 return template.HTML(fmt.Sprintf(`<div class="%s rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">%s</div>`, sizeClass, icon))
408 },
409 "profileAvatarUrl": func(profile *models.Profile, size string) string {
410 if profile != nil {
411 return p.AvatarUrl(profile.Did, size)
412 }
413 return ""
414 },
415 "langColor": enry.GetColor,
416 "reverse": func(s any) any {
417 if s == nil {
418 return nil
419 }
420
421 v := reflect.ValueOf(s)
422
423 if v.Kind() != reflect.Slice {
424 return s
425 }
426
427 length := v.Len()
428 reversed := reflect.MakeSlice(v.Type(), length, length)
429
430 for i := range length {
431 reversed.Index(i).Set(v.Index(length - 1 - i))
432 }
433
434 return reversed.Interface()
435 },
436 "normalizeForHtmlId": func(s string) string {
437 normalized := strings.ReplaceAll(s, ":", "_")
438 normalized = strings.ReplaceAll(normalized, ".", "_")
439 return normalized
440 },
441 "sshFingerprint": func(pubKey string) string {
442 fp, err := crypto.SSHFingerprint(pubKey)
443 if err != nil {
444 return "error"
445 }
446 return fp
447 },
448 "otherAccounts": func(activeDid string, accounts []oauth.AccountInfo) []oauth.AccountInfo {
449 result := make([]oauth.AccountInfo, 0, len(accounts))
450 for _, acc := range accounts {
451 if acc.Did != activeDid {
452 result = append(result, acc)
453 }
454 }
455 return result
456 },
457 "isGenerated": func(path string) bool {
458 return enry.IsGenerated(path, nil)
459 },
460 // constant values used to define a template
461 "const": func() map[string]any {
462 return map[string]any{
463 "OrderedReactionKinds": models.OrderedReactionKinds,
464 // would be great to have ordered maps right about now
465 "UserSettingsTabs": []tab{
466 {"Name": "profile", "Icon": "user"},
467 {"Name": "keys", "Icon": "key"},
468 {"Name": "emails", "Icon": "mail"},
469 {"Name": "notifications", "Icon": "bell"},
470 {"Name": "knots", "Icon": "volleyball"},
471 {"Name": "spindles", "Icon": "spool"},
472 {"Name": "sites", "Icon": "globe"},
473 },
474 "RepoSettingsTabs": []tab{
475 {"Name": "general", "Icon": "sliders-horizontal"},
476 {"Name": "access", "Icon": "users"},
477 {"Name": "pipelines", "Icon": "layers-2"},
478 {"Name": "hooks", "Icon": "webhook"},
479 {"Name": "sites", "Icon": "globe"},
480 },
481 }
482 },
483 }
484}
485
486func (p *Pages) resolveDid(did string) string {
487 identity, err := p.resolver.ResolveIdent(context.Background(), did)
488
489 if err != nil {
490 return did
491 }
492
493 if identity.Handle.IsInvalidHandle() {
494 return "handle.invalid"
495 }
496
497 return identity.Handle.String()
498}
499
500func (p *Pages) AvatarUrl(actor, size string) string {
501 actor = strings.TrimPrefix(actor, "@")
502
503 identity, err := p.resolver.ResolveIdent(context.Background(), actor)
504 var did string
505 if err != nil {
506 did = actor
507 } else {
508 did = identity.DID.String()
509 }
510
511 secret := p.avatar.SharedSecret
512 h := hmac.New(sha256.New, []byte(secret))
513 h.Write([]byte(did))
514 signature := hex.EncodeToString(h.Sum(nil))
515
516 // Get avatar CID for cache busting
517 profile, err := db.GetProfile(p.db, did)
518 version := ""
519 if err == nil && profile != nil && profile.Avatar != "" {
520 // Use first 8 chars of avatar CID as version
521 if len(profile.Avatar) > 8 {
522 version = profile.Avatar[:8]
523 } else {
524 version = profile.Avatar
525 }
526 }
527
528 baseUrl := fmt.Sprintf("%s/%s/%s", p.avatar.Host, signature, did)
529 if size != "" {
530 if version != "" {
531 return fmt.Sprintf("%s?size=%s&v=%s", baseUrl, size, version)
532 }
533 return fmt.Sprintf("%s?size=%s", baseUrl, size)
534 }
535 if version != "" {
536 return fmt.Sprintf("%s?v=%s", baseUrl, version)
537 }
538 return baseUrl
539}
540
541func (p *Pages) icon(name string, classes []string) (template.HTML, error) {
542 iconPath := filepath.Join("static", "icons", name)
543
544 if filepath.Ext(name) == "" {
545 iconPath += ".svg"
546 }
547
548 data, err := Files.ReadFile(iconPath)
549 if err != nil {
550 return "", fmt.Errorf("icon %s not found: %w", name, err)
551 }
552
553 // Convert SVG data to string
554 svgStr := string(data)
555
556 svgTagEnd := strings.Index(svgStr, ">")
557 if svgTagEnd == -1 {
558 return "", fmt.Errorf("invalid SVG format for icon %s", name)
559 }
560
561 classTag := ` class="` + strings.Join(classes, " ") + `"`
562
563 modifiedSVG := svgStr[:svgTagEnd] + classTag + svgStr[svgTagEnd:]
564 return template.HTML(modifiedSVG), nil
565}
566
567func durationFmt(duration time.Duration, names [4]string) string {
568 days := int64(duration.Hours() / 24)
569 hours := int64(math.Mod(duration.Hours(), 24))
570 minutes := int64(math.Mod(duration.Minutes(), 60))
571 seconds := int64(math.Mod(duration.Seconds(), 60))
572
573 chunks := []struct {
574 name string
575 amount int64
576 }{
577 {names[0], days},
578 {names[1], hours},
579 {names[2], minutes},
580 {names[3], seconds},
581 }
582
583 parts := []string{}
584
585 for _, chunk := range chunks {
586 if chunk.amount != 0 {
587 parts = append(parts, fmt.Sprintf("%d%s", chunk.amount, chunk.name))
588 }
589 }
590
591 return strings.Join(parts, " ")
592}