tangled
alpha
login
or
join now
back
round
0
view raw
appview/repo/ogcard: package to help draw image cards
#647
merged
opened by
anirudh.fi
3 months ago
targeting
master
from
push-vyusnwqnmxwy
Borrowed from Forgejo + repurposed. <3
Signed-off-by: Anirudh Oppiliappan
anirudh@tangled.org
options
unified
split
Changed files
+510
-13
appview
repo
ogcard
card.go
go.mod
go.sum
+500
appview/repo/ogcard/card.go
···
1
1
+
// Copyright 2024 The Forgejo Authors. All rights reserved.
2
2
+
// Copyright 2025 The Tangled Authors -- repurposed for Tangled use.
3
3
+
// SPDX-License-Identifier: MIT
4
4
+
5
5
+
package ogcard
6
6
+
7
7
+
import (
8
8
+
"bytes"
9
9
+
"fmt"
10
10
+
"image"
11
11
+
"image/color"
12
12
+
"io"
13
13
+
"log"
14
14
+
"math"
15
15
+
"net/http"
16
16
+
"strings"
17
17
+
"sync"
18
18
+
"time"
19
19
+
20
20
+
"github.com/goki/freetype"
21
21
+
"github.com/goki/freetype/truetype"
22
22
+
"github.com/srwiley/oksvg"
23
23
+
"github.com/srwiley/rasterx"
24
24
+
"golang.org/x/image/draw"
25
25
+
"golang.org/x/image/font"
26
26
+
"tangled.org/core/appview/pages"
27
27
+
28
28
+
_ "golang.org/x/image/webp" // for processing webp images
29
29
+
)
30
30
+
31
31
+
type Card struct {
32
32
+
Img *image.RGBA
33
33
+
Font *truetype.Font
34
34
+
Margin int
35
35
+
Width int
36
36
+
Height int
37
37
+
}
38
38
+
39
39
+
var fontCache = sync.OnceValues(func() (*truetype.Font, error) {
40
40
+
interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf")
41
41
+
if err != nil {
42
42
+
return nil, err
43
43
+
}
44
44
+
return truetype.Parse(interVar)
45
45
+
})
46
46
+
47
47
+
// DefaultSize returns the default size for a card
48
48
+
func DefaultSize() (int, int) {
49
49
+
return 1200, 600
50
50
+
}
51
51
+
52
52
+
// NewCard creates a new card with the given dimensions in pixels
53
53
+
func NewCard(width, height int) (*Card, error) {
54
54
+
img := image.NewRGBA(image.Rect(0, 0, width, height))
55
55
+
draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
56
56
+
57
57
+
font, err := fontCache()
58
58
+
if err != nil {
59
59
+
return nil, err
60
60
+
}
61
61
+
62
62
+
return &Card{
63
63
+
Img: img,
64
64
+
Font: font,
65
65
+
Margin: 0,
66
66
+
Width: width,
67
67
+
Height: height,
68
68
+
}, nil
69
69
+
}
70
70
+
71
71
+
// Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage
72
72
+
// size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer.
73
73
+
func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) {
74
74
+
bounds := c.Img.Bounds()
75
75
+
bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
76
76
+
if vertical {
77
77
+
mid := (bounds.Dx() * percentage / 100) + bounds.Min.X
78
78
+
subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA)
79
79
+
subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
80
80
+
return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()},
81
81
+
&Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()}
82
82
+
}
83
83
+
mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y
84
84
+
subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA)
85
85
+
subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
86
86
+
return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()},
87
87
+
&Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()}
88
88
+
}
89
89
+
90
90
+
// SetMargin sets the margins for the card
91
91
+
func (c *Card) SetMargin(margin int) {
92
92
+
c.Margin = margin
93
93
+
}
94
94
+
95
95
+
type (
96
96
+
VAlign int64
97
97
+
HAlign int64
98
98
+
)
99
99
+
100
100
+
const (
101
101
+
Top VAlign = iota
102
102
+
Middle
103
103
+
Bottom
104
104
+
)
105
105
+
106
106
+
const (
107
107
+
Left HAlign = iota
108
108
+
Center
109
109
+
Right
110
110
+
)
111
111
+
112
112
+
// DrawText draws text within the card, respecting margins and alignment
113
113
+
func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) {
114
114
+
ft := freetype.NewContext()
115
115
+
ft.SetDPI(72)
116
116
+
ft.SetFont(c.Font)
117
117
+
ft.SetFontSize(sizePt)
118
118
+
ft.SetClip(c.Img.Bounds())
119
119
+
ft.SetDst(c.Img)
120
120
+
ft.SetSrc(image.NewUniform(textColor))
121
121
+
122
122
+
face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
123
123
+
fontHeight := ft.PointToFixed(sizePt).Ceil()
124
124
+
125
125
+
bounds := c.Img.Bounds()
126
126
+
bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
127
127
+
boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y
128
128
+
// draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box
129
129
+
130
130
+
// Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move
131
131
+
// on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires
132
132
+
// knowing the total height, which is related to how many lines we'll have.
133
133
+
lines := make([]string, 0)
134
134
+
textWords := strings.Split(text, " ")
135
135
+
currentLine := ""
136
136
+
heightTotal := 0
137
137
+
138
138
+
for {
139
139
+
if len(textWords) == 0 {
140
140
+
// Ran out of words.
141
141
+
if currentLine != "" {
142
142
+
heightTotal += fontHeight
143
143
+
lines = append(lines, currentLine)
144
144
+
}
145
145
+
break
146
146
+
}
147
147
+
148
148
+
nextWord := textWords[0]
149
149
+
proposedLine := currentLine
150
150
+
if proposedLine != "" {
151
151
+
proposedLine += " "
152
152
+
}
153
153
+
proposedLine += nextWord
154
154
+
155
155
+
proposedLineWidth := font.MeasureString(face, proposedLine)
156
156
+
if proposedLineWidth.Ceil() > boxWidth {
157
157
+
// no, proposed line is too big; we'll use the last "currentLine"
158
158
+
heightTotal += fontHeight
159
159
+
if currentLine != "" {
160
160
+
lines = append(lines, currentLine)
161
161
+
currentLine = ""
162
162
+
// leave nextWord in textWords and keep going
163
163
+
} else {
164
164
+
// just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it
165
165
+
// regardless as a line by itself. It will be clipped by the drawing routine.
166
166
+
lines = append(lines, nextWord)
167
167
+
textWords = textWords[1:]
168
168
+
}
169
169
+
} else {
170
170
+
// yes, it will fit
171
171
+
currentLine = proposedLine
172
172
+
textWords = textWords[1:]
173
173
+
}
174
174
+
}
175
175
+
176
176
+
textY := 0
177
177
+
switch valign {
178
178
+
case Top:
179
179
+
textY = fontHeight
180
180
+
case Bottom:
181
181
+
textY = boxHeight - heightTotal + fontHeight
182
182
+
case Middle:
183
183
+
textY = ((boxHeight - heightTotal) / 2) + fontHeight
184
184
+
}
185
185
+
186
186
+
for _, line := range lines {
187
187
+
lineWidth := font.MeasureString(face, line)
188
188
+
189
189
+
textX := 0
190
190
+
switch halign {
191
191
+
case Left:
192
192
+
textX = 0
193
193
+
case Right:
194
194
+
textX = boxWidth - lineWidth.Ceil()
195
195
+
case Center:
196
196
+
textX = (boxWidth - lineWidth.Ceil()) / 2
197
197
+
}
198
198
+
199
199
+
pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY)
200
200
+
_, err := ft.DrawString(line, pt)
201
201
+
if err != nil {
202
202
+
return nil, err
203
203
+
}
204
204
+
205
205
+
textY += fontHeight
206
206
+
}
207
207
+
208
208
+
return lines, nil
209
209
+
}
210
210
+
211
211
+
// DrawTextAt draws text at a specific position with the given alignment
212
212
+
func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error {
213
213
+
_, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign)
214
214
+
return err
215
215
+
}
216
216
+
217
217
+
// DrawTextAtWithWidth draws text at a specific position and returns the text width
218
218
+
func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
219
219
+
ft := freetype.NewContext()
220
220
+
ft.SetDPI(72)
221
221
+
ft.SetFont(c.Font)
222
222
+
ft.SetFontSize(sizePt)
223
223
+
ft.SetClip(c.Img.Bounds())
224
224
+
ft.SetDst(c.Img)
225
225
+
ft.SetSrc(image.NewUniform(textColor))
226
226
+
227
227
+
face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
228
228
+
fontHeight := ft.PointToFixed(sizePt).Ceil()
229
229
+
lineWidth := font.MeasureString(face, text)
230
230
+
textWidth := lineWidth.Ceil()
231
231
+
232
232
+
// Adjust position based on alignment
233
233
+
adjustedX := x
234
234
+
adjustedY := y
235
235
+
236
236
+
switch halign {
237
237
+
case Left:
238
238
+
// x is already at the left position
239
239
+
case Right:
240
240
+
adjustedX = x - textWidth
241
241
+
case Center:
242
242
+
adjustedX = x - textWidth/2
243
243
+
}
244
244
+
245
245
+
switch valign {
246
246
+
case Top:
247
247
+
adjustedY = y + fontHeight
248
248
+
case Bottom:
249
249
+
adjustedY = y
250
250
+
case Middle:
251
251
+
adjustedY = y + fontHeight/2
252
252
+
}
253
253
+
254
254
+
pt := freetype.Pt(adjustedX, adjustedY)
255
255
+
_, err := ft.DrawString(text, pt)
256
256
+
return textWidth, err
257
257
+
}
258
258
+
259
259
+
// DrawBoldText draws bold text by rendering multiple times with slight offsets
260
260
+
func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
261
261
+
// Draw the text multiple times with slight offsets to create bold effect
262
262
+
offsets := []struct{ dx, dy int }{
263
263
+
{0, 0}, // original
264
264
+
{1, 0}, // right
265
265
+
{0, 1}, // down
266
266
+
{1, 1}, // diagonal
267
267
+
}
268
268
+
269
269
+
var width int
270
270
+
for _, offset := range offsets {
271
271
+
w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign)
272
272
+
if err != nil {
273
273
+
return 0, err
274
274
+
}
275
275
+
if width == 0 {
276
276
+
width = w
277
277
+
}
278
278
+
}
279
279
+
return width, nil
280
280
+
}
281
281
+
282
282
+
// DrawSVGIcon draws an SVG icon from the embedded files at the specified position
283
283
+
func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error {
284
284
+
svgData, err := pages.Files.ReadFile(svgPath)
285
285
+
if err != nil {
286
286
+
return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err)
287
287
+
}
288
288
+
289
289
+
// Convert color to hex string for SVG
290
290
+
rgba, isRGBA := iconColor.(color.RGBA)
291
291
+
if !isRGBA {
292
292
+
r, g, b, a := iconColor.RGBA()
293
293
+
rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)}
294
294
+
}
295
295
+
colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
296
296
+
297
297
+
// Replace currentColor with our desired color in the SVG
298
298
+
svgString := string(svgData)
299
299
+
svgString = strings.ReplaceAll(svgString, "currentColor", colorHex)
300
300
+
301
301
+
// Make the stroke thicker
302
302
+
svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`)
303
303
+
304
304
+
// Parse SVG
305
305
+
icon, err := oksvg.ReadIconStream(strings.NewReader(svgString))
306
306
+
if err != nil {
307
307
+
return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err)
308
308
+
}
309
309
+
310
310
+
// Set the icon size
311
311
+
w, h := float64(size), float64(size)
312
312
+
icon.SetTarget(0, 0, w, h)
313
313
+
314
314
+
// Create a temporary RGBA image for the icon
315
315
+
iconImg := image.NewRGBA(image.Rect(0, 0, size, size))
316
316
+
317
317
+
// Create scanner and rasterizer
318
318
+
scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds())
319
319
+
raster := rasterx.NewDasher(size, size, scanner)
320
320
+
321
321
+
// Draw the icon
322
322
+
icon.Draw(raster, 1.0)
323
323
+
324
324
+
// Draw the icon onto the card at the specified position
325
325
+
bounds := c.Img.Bounds()
326
326
+
destRect := image.Rect(x, y, x+size, y+size)
327
327
+
328
328
+
// Make sure we don't draw outside the card bounds
329
329
+
if destRect.Max.X > bounds.Max.X {
330
330
+
destRect.Max.X = bounds.Max.X
331
331
+
}
332
332
+
if destRect.Max.Y > bounds.Max.Y {
333
333
+
destRect.Max.Y = bounds.Max.Y
334
334
+
}
335
335
+
336
336
+
draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over)
337
337
+
338
338
+
return nil
339
339
+
}
340
340
+
341
341
+
// DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension
342
342
+
func (c *Card) DrawImage(img image.Image) {
343
343
+
bounds := c.Img.Bounds()
344
344
+
targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
345
345
+
srcBounds := img.Bounds()
346
346
+
srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy())
347
347
+
targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy())
348
348
+
349
349
+
var scale float64
350
350
+
if srcAspect > targetAspect {
351
351
+
// Image is wider than target, scale by width
352
352
+
scale = float64(targetRect.Dx()) / float64(srcBounds.Dx())
353
353
+
} else {
354
354
+
// Image is taller or equal, scale by height
355
355
+
scale = float64(targetRect.Dy()) / float64(srcBounds.Dy())
356
356
+
}
357
357
+
358
358
+
newWidth := int(math.Round(float64(srcBounds.Dx()) * scale))
359
359
+
newHeight := int(math.Round(float64(srcBounds.Dy()) * scale))
360
360
+
361
361
+
// Center the image within the target rectangle
362
362
+
offsetX := (targetRect.Dx() - newWidth) / 2
363
363
+
offsetY := (targetRect.Dy() - newHeight) / 2
364
364
+
365
365
+
scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight)
366
366
+
draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil)
367
367
+
}
368
368
+
369
369
+
func fallbackImage() image.Image {
370
370
+
// can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage
371
371
+
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
372
372
+
img.Set(0, 0, color.White)
373
373
+
return img
374
374
+
}
375
375
+
376
376
+
// As defensively as possible, attempt to load an image from a presumed external and untrusted URL
377
377
+
func (c *Card) fetchExternalImage(url string) (image.Image, bool) {
378
378
+
// Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want
379
379
+
// this rendering process to be slowed down
380
380
+
client := &http.Client{
381
381
+
Timeout: 1 * time.Second, // 1 second timeout
382
382
+
}
383
383
+
384
384
+
resp, err := client.Get(url)
385
385
+
if err != nil {
386
386
+
log.Printf("error when fetching external image from %s: %v", url, err)
387
387
+
return nil, false
388
388
+
}
389
389
+
defer resp.Body.Close()
390
390
+
391
391
+
if resp.StatusCode != http.StatusOK {
392
392
+
log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status)
393
393
+
return nil, false
394
394
+
}
395
395
+
396
396
+
contentType := resp.Header.Get("Content-Type")
397
397
+
// Support content types are in-sync with the allowed custom avatar file types
398
398
+
if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" {
399
399
+
log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType)
400
400
+
return nil, false
401
401
+
}
402
402
+
403
403
+
body := resp.Body
404
404
+
bodyBytes, err := io.ReadAll(body)
405
405
+
if err != nil {
406
406
+
log.Printf("error when fetching external image from %s: %v", url, err)
407
407
+
return nil, false
408
408
+
}
409
409
+
410
410
+
bodyBuffer := bytes.NewReader(bodyBytes)
411
411
+
_, imgType, err := image.DecodeConfig(bodyBuffer)
412
412
+
if err != nil {
413
413
+
log.Printf("error when decoding external image from %s: %v", url, err)
414
414
+
return nil, false
415
415
+
}
416
416
+
417
417
+
// Verify that we have a match between actual data understood in the image body and the reported Content-Type
418
418
+
if (contentType == "image/png" && imgType != "png") ||
419
419
+
(contentType == "image/jpeg" && imgType != "jpeg") ||
420
420
+
(contentType == "image/gif" && imgType != "gif") ||
421
421
+
(contentType == "image/webp" && imgType != "webp") {
422
422
+
log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType)
423
423
+
return nil, false
424
424
+
}
425
425
+
426
426
+
_, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode
427
427
+
if err != nil {
428
428
+
log.Printf("error w/ bodyBuffer.Seek")
429
429
+
return nil, false
430
430
+
}
431
431
+
img, _, err := image.Decode(bodyBuffer)
432
432
+
if err != nil {
433
433
+
log.Printf("error when decoding external image from %s: %v", url, err)
434
434
+
return nil, false
435
435
+
}
436
436
+
437
437
+
return img, true
438
438
+
}
439
439
+
440
440
+
func (c *Card) DrawExternalImage(url string) {
441
441
+
image, ok := c.fetchExternalImage(url)
442
442
+
if !ok {
443
443
+
image = fallbackImage()
444
444
+
}
445
445
+
c.DrawImage(image)
446
446
+
}
447
447
+
448
448
+
// DrawCircularExternalImage draws an external image as a circle at the specified position
449
449
+
func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error {
450
450
+
img, ok := c.fetchExternalImage(url)
451
451
+
if !ok {
452
452
+
img = fallbackImage()
453
453
+
}
454
454
+
455
455
+
// Create a circular mask
456
456
+
circle := image.NewRGBA(image.Rect(0, 0, size, size))
457
457
+
center := size / 2
458
458
+
radius := float64(size / 2)
459
459
+
460
460
+
// Scale the source image to fit the circle
461
461
+
srcBounds := img.Bounds()
462
462
+
scaledImg := image.NewRGBA(image.Rect(0, 0, size, size))
463
463
+
draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil)
464
464
+
465
465
+
// Draw the image with circular clipping
466
466
+
for cy := 0; cy < size; cy++ {
467
467
+
for cx := 0; cx < size; cx++ {
468
468
+
// Calculate distance from center
469
469
+
dx := float64(cx - center)
470
470
+
dy := float64(cy - center)
471
471
+
distance := math.Sqrt(dx*dx + dy*dy)
472
472
+
473
473
+
// Only draw pixels within the circle
474
474
+
if distance <= radius {
475
475
+
circle.Set(cx, cy, scaledImg.At(cx, cy))
476
476
+
}
477
477
+
}
478
478
+
}
479
479
+
480
480
+
// Draw the circle onto the card
481
481
+
bounds := c.Img.Bounds()
482
482
+
destRect := image.Rect(x, y, x+size, y+size)
483
483
+
484
484
+
// Make sure we don't draw outside the card bounds
485
485
+
if destRect.Max.X > bounds.Max.X {
486
486
+
destRect.Max.X = bounds.Max.X
487
487
+
}
488
488
+
if destRect.Max.Y > bounds.Max.Y {
489
489
+
destRect.Max.Y = bounds.Max.Y
490
490
+
}
491
491
+
492
492
+
draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over)
493
493
+
494
494
+
return nil
495
495
+
}
496
496
+
497
497
+
// DrawRect draws a rect with the given color
498
498
+
func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) {
499
499
+
draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src)
500
500
+
}
+4
-3
go.mod
···
21
21
github.com/go-chi/chi/v5 v5.2.0
22
22
github.com/go-enry/go-enry/v2 v2.9.2
23
23
github.com/go-git/go-git/v5 v5.14.0
24
24
+
github.com/goki/freetype v1.0.5
24
25
github.com/google/uuid v1.6.0
25
26
github.com/gorilla/feeds v1.2.0
26
27
github.com/gorilla/sessions v1.4.0
···
44
45
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
45
46
golang.org/x/crypto v0.40.0
46
47
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
48
48
+
golang.org/x/image v0.31.0
47
49
golang.org/x/net v0.42.0
48
48
-
golang.org/x/sync v0.16.0
50
50
+
golang.org/x/sync v0.17.0
49
51
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
50
52
gopkg.in/yaml.v3 v3.0.1
51
51
-
tangled.org/anirudh.fi/atproto-oauth v0.0.0-20251004062652-69f4561572b5
52
53
)
53
54
54
55
require (
···
170
171
go.uber.org/multierr v1.11.0 // indirect
171
172
go.uber.org/zap v1.27.0 // indirect
172
173
golang.org/x/sys v0.34.0 // indirect
173
173
-
golang.org/x/text v0.27.0 // indirect
174
174
+
golang.org/x/text v0.29.0 // indirect
174
175
golang.org/x/time v0.12.0 // indirect
175
176
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
176
177
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
+6
-10
go.sum
···
23
23
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
24
24
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
25
25
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
26
26
-
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ=
27
27
-
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q=
28
26
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU=
29
27
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8=
30
28
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
···
245
243
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
246
244
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
247
245
github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY=
248
248
-
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
249
249
-
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
250
246
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
251
247
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
252
248
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
···
491
487
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
492
488
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
493
489
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
490
490
+
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
491
491
+
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
494
492
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
495
493
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
496
494
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
···
530
528
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
531
529
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
532
530
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
533
533
-
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
534
534
-
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
531
531
+
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
532
532
+
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
535
533
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
536
534
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
537
535
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
585
583
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
586
584
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
587
585
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
588
588
-
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
589
589
-
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
586
586
+
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
587
587
+
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
590
588
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
591
589
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
592
590
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
···
654
652
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
655
653
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
656
654
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
657
657
-
tangled.org/anirudh.fi/atproto-oauth v0.0.0-20251004062652-69f4561572b5 h1:EpQ9MT09jSf4Zjs1+yFvB4CD/fBkFdx8UaDJDwO1Jk8=
658
658
-
tangled.org/anirudh.fi/atproto-oauth v0.0.0-20251004062652-69f4561572b5/go.mod h1:BQFGoN2V+h5KtgKsQgWU73R55ILdDy/R5RZTrZi6wog=
659
655
tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
660
656
tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=