forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

Compare changes

Choose any two refs to compare.

Changed files
+15 -661
appview
pages
templates
fragments
repo
ogcard
state
nix
-44
appview/pages/templates/fragments/dolly/silhouette.svg
··· 1 - <svg 2 - version="1.1" 3 - id="svg1" 4 - width="32" 5 - height="32" 6 - viewBox="0 0 25 25" 7 - sodipodi:docname="tangled_dolly_silhouette.png" 8 - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 9 - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 10 - xmlns="http://www.w3.org/2000/svg" 11 - xmlns:svg="http://www.w3.org/2000/svg"> 12 - <title>Dolly</title> 13 - <defs 14 - id="defs1" /> 15 - <sodipodi:namedview 16 - id="namedview1" 17 - pagecolor="#ffffff" 18 - bordercolor="#000000" 19 - borderopacity="0.25" 20 - inkscape:showpageshadow="2" 21 - inkscape:pageopacity="0.0" 22 - inkscape:pagecheckerboard="true" 23 - inkscape:deskcolor="#d1d1d1"> 24 - <inkscape:page 25 - x="0" 26 - y="0" 27 - width="25" 28 - height="25" 29 - id="page2" 30 - margin="0" 31 - bleed="0" /> 32 - </sodipodi:namedview> 33 - <g 34 - inkscape:groupmode="layer" 35 - inkscape:label="Image" 36 - id="g1"> 37 - <path 38 - class="dolly" 39 - fill="currentColor" 40 - style="stroke-width:1.12248" 41 - d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z" 42 - id="path1" /> 43 - </g> 44 - </svg>
···
-5
appview/repo/index.go
··· 220 if err != nil { 221 return nil, err 222 } 223 - 224 - err = tx.Commit() 225 - if err != nil { 226 - return nil, err 227 - } 228 } 229 230 var total int64
··· 220 if err != nil { 221 return nil, err 222 } 223 } 224 225 var total int64
-500
appview/repo/ogcard/card.go
··· 1 - // Copyright 2024 The Forgejo Authors. All rights reserved. 2 - // Copyright 2025 The Tangled Authors -- repurposed for Tangled use. 3 - // SPDX-License-Identifier: MIT 4 - 5 - package ogcard 6 - 7 - import ( 8 - "bytes" 9 - "fmt" 10 - "image" 11 - "image/color" 12 - "io" 13 - "log" 14 - "math" 15 - "net/http" 16 - "strings" 17 - "sync" 18 - "time" 19 - 20 - "github.com/goki/freetype" 21 - "github.com/goki/freetype/truetype" 22 - "github.com/srwiley/oksvg" 23 - "github.com/srwiley/rasterx" 24 - "golang.org/x/image/draw" 25 - "golang.org/x/image/font" 26 - "tangled.org/core/appview/pages" 27 - 28 - _ "golang.org/x/image/webp" // for processing webp images 29 - ) 30 - 31 - type Card struct { 32 - Img *image.RGBA 33 - Font *truetype.Font 34 - Margin int 35 - Width int 36 - Height int 37 - } 38 - 39 - var fontCache = sync.OnceValues(func() (*truetype.Font, error) { 40 - interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf") 41 - if err != nil { 42 - return nil, err 43 - } 44 - return truetype.Parse(interVar) 45 - }) 46 - 47 - // DefaultSize returns the default size for a card 48 - func DefaultSize() (int, int) { 49 - return 1200, 630 50 - } 51 - 52 - // NewCard creates a new card with the given dimensions in pixels 53 - func NewCard(width, height int) (*Card, error) { 54 - img := image.NewRGBA(image.Rect(0, 0, width, height)) 55 - draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) 56 - 57 - font, err := fontCache() 58 - if err != nil { 59 - return nil, err 60 - } 61 - 62 - return &Card{ 63 - Img: img, 64 - Font: font, 65 - Margin: 0, 66 - Width: width, 67 - Height: height, 68 - }, nil 69 - } 70 - 71 - // Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage 72 - // size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer. 73 - func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) { 74 - bounds := c.Img.Bounds() 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 - if vertical { 77 - mid := (bounds.Dx() * percentage / 100) + bounds.Min.X 78 - subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA) 79 - subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 80 - return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()}, 81 - &Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()} 82 - } 83 - mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y 84 - subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA) 85 - subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 86 - return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()}, 87 - &Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()} 88 - } 89 - 90 - // SetMargin sets the margins for the card 91 - func (c *Card) SetMargin(margin int) { 92 - c.Margin = margin 93 - } 94 - 95 - type ( 96 - VAlign int64 97 - HAlign int64 98 - ) 99 - 100 - const ( 101 - Top VAlign = iota 102 - Middle 103 - Bottom 104 - ) 105 - 106 - const ( 107 - Left HAlign = iota 108 - Center 109 - Right 110 - ) 111 - 112 - // DrawText draws text within the card, respecting margins and alignment 113 - func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) { 114 - ft := freetype.NewContext() 115 - ft.SetDPI(72) 116 - ft.SetFont(c.Font) 117 - ft.SetFontSize(sizePt) 118 - ft.SetClip(c.Img.Bounds()) 119 - ft.SetDst(c.Img) 120 - ft.SetSrc(image.NewUniform(textColor)) 121 - 122 - face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 123 - fontHeight := ft.PointToFixed(sizePt).Ceil() 124 - 125 - bounds := c.Img.Bounds() 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 - boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y 128 - // draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box 129 - 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 - // on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires 132 - // knowing the total height, which is related to how many lines we'll have. 133 - lines := make([]string, 0) 134 - textWords := strings.Split(text, " ") 135 - currentLine := "" 136 - heightTotal := 0 137 - 138 - for { 139 - if len(textWords) == 0 { 140 - // Ran out of words. 141 - if currentLine != "" { 142 - heightTotal += fontHeight 143 - lines = append(lines, currentLine) 144 - } 145 - break 146 - } 147 - 148 - nextWord := textWords[0] 149 - proposedLine := currentLine 150 - if proposedLine != "" { 151 - proposedLine += " " 152 - } 153 - proposedLine += nextWord 154 - 155 - proposedLineWidth := font.MeasureString(face, proposedLine) 156 - if proposedLineWidth.Ceil() > boxWidth { 157 - // no, proposed line is too big; we'll use the last "currentLine" 158 - heightTotal += fontHeight 159 - if currentLine != "" { 160 - lines = append(lines, currentLine) 161 - currentLine = "" 162 - // leave nextWord in textWords and keep going 163 - } else { 164 - // just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it 165 - // regardless as a line by itself. It will be clipped by the drawing routine. 166 - lines = append(lines, nextWord) 167 - textWords = textWords[1:] 168 - } 169 - } else { 170 - // yes, it will fit 171 - currentLine = proposedLine 172 - textWords = textWords[1:] 173 - } 174 - } 175 - 176 - textY := 0 177 - switch valign { 178 - case Top: 179 - textY = fontHeight 180 - case Bottom: 181 - textY = boxHeight - heightTotal + fontHeight 182 - case Middle: 183 - textY = ((boxHeight - heightTotal) / 2) + fontHeight 184 - } 185 - 186 - for _, line := range lines { 187 - lineWidth := font.MeasureString(face, line) 188 - 189 - textX := 0 190 - switch halign { 191 - case Left: 192 - textX = 0 193 - case Right: 194 - textX = boxWidth - lineWidth.Ceil() 195 - case Center: 196 - textX = (boxWidth - lineWidth.Ceil()) / 2 197 - } 198 - 199 - pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY) 200 - _, err := ft.DrawString(line, pt) 201 - if err != nil { 202 - return nil, err 203 - } 204 - 205 - textY += fontHeight 206 - } 207 - 208 - return lines, nil 209 - } 210 - 211 - // DrawTextAt draws text at a specific position with the given alignment 212 - func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error { 213 - _, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign) 214 - return err 215 - } 216 - 217 - // DrawTextAtWithWidth draws text at a specific position and returns the text width 218 - func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 219 - ft := freetype.NewContext() 220 - ft.SetDPI(72) 221 - ft.SetFont(c.Font) 222 - ft.SetFontSize(sizePt) 223 - ft.SetClip(c.Img.Bounds()) 224 - ft.SetDst(c.Img) 225 - ft.SetSrc(image.NewUniform(textColor)) 226 - 227 - face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 228 - fontHeight := ft.PointToFixed(sizePt).Ceil() 229 - lineWidth := font.MeasureString(face, text) 230 - textWidth := lineWidth.Ceil() 231 - 232 - // Adjust position based on alignment 233 - adjustedX := x 234 - adjustedY := y 235 - 236 - switch halign { 237 - case Left: 238 - // x is already at the left position 239 - case Right: 240 - adjustedX = x - textWidth 241 - case Center: 242 - adjustedX = x - textWidth/2 243 - } 244 - 245 - switch valign { 246 - case Top: 247 - adjustedY = y + fontHeight 248 - case Bottom: 249 - adjustedY = y 250 - case Middle: 251 - adjustedY = y + fontHeight/2 252 - } 253 - 254 - pt := freetype.Pt(adjustedX, adjustedY) 255 - _, err := ft.DrawString(text, pt) 256 - return textWidth, err 257 - } 258 - 259 - // DrawBoldText draws bold text by rendering multiple times with slight offsets 260 - func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 261 - // Draw the text multiple times with slight offsets to create bold effect 262 - offsets := []struct{ dx, dy int }{ 263 - {0, 0}, // original 264 - {1, 0}, // right 265 - {0, 1}, // down 266 - {1, 1}, // diagonal 267 - } 268 - 269 - var width int 270 - for _, offset := range offsets { 271 - w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign) 272 - if err != nil { 273 - return 0, err 274 - } 275 - if width == 0 { 276 - width = w 277 - } 278 - } 279 - return width, nil 280 - } 281 - 282 - // DrawSVGIcon draws an SVG icon from the embedded files at the specified position 283 - func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error { 284 - svgData, err := pages.Files.ReadFile(svgPath) 285 - if err != nil { 286 - return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err) 287 - } 288 - 289 - // Convert color to hex string for SVG 290 - rgba, isRGBA := iconColor.(color.RGBA) 291 - if !isRGBA { 292 - r, g, b, a := iconColor.RGBA() 293 - rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)} 294 - } 295 - colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) 296 - 297 - // Replace currentColor with our desired color in the SVG 298 - svgString := string(svgData) 299 - svgString = strings.ReplaceAll(svgString, "currentColor", colorHex) 300 - 301 - // Make the stroke thicker 302 - svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`) 303 - 304 - // Parse SVG 305 - icon, err := oksvg.ReadIconStream(strings.NewReader(svgString)) 306 - if err != nil { 307 - return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err) 308 - } 309 - 310 - // Set the icon size 311 - w, h := float64(size), float64(size) 312 - icon.SetTarget(0, 0, w, h) 313 - 314 - // Create a temporary RGBA image for the icon 315 - iconImg := image.NewRGBA(image.Rect(0, 0, size, size)) 316 - 317 - // Create scanner and rasterizer 318 - scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds()) 319 - raster := rasterx.NewDasher(size, size, scanner) 320 - 321 - // Draw the icon 322 - icon.Draw(raster, 1.0) 323 - 324 - // Draw the icon onto the card at the specified position 325 - bounds := c.Img.Bounds() 326 - destRect := image.Rect(x, y, x+size, y+size) 327 - 328 - // Make sure we don't draw outside the card bounds 329 - if destRect.Max.X > bounds.Max.X { 330 - destRect.Max.X = bounds.Max.X 331 - } 332 - if destRect.Max.Y > bounds.Max.Y { 333 - destRect.Max.Y = bounds.Max.Y 334 - } 335 - 336 - draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over) 337 - 338 - return nil 339 - } 340 - 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 - func (c *Card) DrawImage(img image.Image) { 343 - bounds := c.Img.Bounds() 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 - srcBounds := img.Bounds() 346 - srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy()) 347 - targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy()) 348 - 349 - var scale float64 350 - if srcAspect > targetAspect { 351 - // Image is wider than target, scale by width 352 - scale = float64(targetRect.Dx()) / float64(srcBounds.Dx()) 353 - } else { 354 - // Image is taller or equal, scale by height 355 - scale = float64(targetRect.Dy()) / float64(srcBounds.Dy()) 356 - } 357 - 358 - newWidth := int(math.Round(float64(srcBounds.Dx()) * scale)) 359 - newHeight := int(math.Round(float64(srcBounds.Dy()) * scale)) 360 - 361 - // Center the image within the target rectangle 362 - offsetX := (targetRect.Dx() - newWidth) / 2 363 - offsetY := (targetRect.Dy() - newHeight) / 2 364 - 365 - scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight) 366 - draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil) 367 - } 368 - 369 - func fallbackImage() image.Image { 370 - // can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage 371 - img := image.NewRGBA(image.Rect(0, 0, 1, 1)) 372 - img.Set(0, 0, color.White) 373 - return img 374 - } 375 - 376 - // As defensively as possible, attempt to load an image from a presumed external and untrusted URL 377 - func (c *Card) fetchExternalImage(url string) (image.Image, bool) { 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 - // this rendering process to be slowed down 380 - client := &http.Client{ 381 - Timeout: 1 * time.Second, // 1 second timeout 382 - } 383 - 384 - resp, err := client.Get(url) 385 - if err != nil { 386 - log.Printf("error when fetching external image from %s: %v", url, err) 387 - return nil, false 388 - } 389 - defer resp.Body.Close() 390 - 391 - if resp.StatusCode != http.StatusOK { 392 - log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status) 393 - return nil, false 394 - } 395 - 396 - contentType := resp.Header.Get("Content-Type") 397 - // Support content types are in-sync with the allowed custom avatar file types 398 - if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" { 399 - log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType) 400 - return nil, false 401 - } 402 - 403 - body := resp.Body 404 - bodyBytes, err := io.ReadAll(body) 405 - if err != nil { 406 - log.Printf("error when fetching external image from %s: %v", url, err) 407 - return nil, false 408 - } 409 - 410 - bodyBuffer := bytes.NewReader(bodyBytes) 411 - _, imgType, err := image.DecodeConfig(bodyBuffer) 412 - if err != nil { 413 - log.Printf("error when decoding external image from %s: %v", url, err) 414 - return nil, false 415 - } 416 - 417 - // Verify that we have a match between actual data understood in the image body and the reported Content-Type 418 - if (contentType == "image/png" && imgType != "png") || 419 - (contentType == "image/jpeg" && imgType != "jpeg") || 420 - (contentType == "image/gif" && imgType != "gif") || 421 - (contentType == "image/webp" && imgType != "webp") { 422 - log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType) 423 - return nil, false 424 - } 425 - 426 - _, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode 427 - if err != nil { 428 - log.Printf("error w/ bodyBuffer.Seek") 429 - return nil, false 430 - } 431 - img, _, err := image.Decode(bodyBuffer) 432 - if err != nil { 433 - log.Printf("error when decoding external image from %s: %v", url, err) 434 - return nil, false 435 - } 436 - 437 - return img, true 438 - } 439 - 440 - func (c *Card) DrawExternalImage(url string) { 441 - image, ok := c.fetchExternalImage(url) 442 - if !ok { 443 - image = fallbackImage() 444 - } 445 - c.DrawImage(image) 446 - } 447 - 448 - // DrawCircularExternalImage draws an external image as a circle at the specified position 449 - func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error { 450 - img, ok := c.fetchExternalImage(url) 451 - if !ok { 452 - img = fallbackImage() 453 - } 454 - 455 - // Create a circular mask 456 - circle := image.NewRGBA(image.Rect(0, 0, size, size)) 457 - center := size / 2 458 - radius := float64(size / 2) 459 - 460 - // Scale the source image to fit the circle 461 - srcBounds := img.Bounds() 462 - scaledImg := image.NewRGBA(image.Rect(0, 0, size, size)) 463 - draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 464 - 465 - // Draw the image with circular clipping 466 - for cy := 0; cy < size; cy++ { 467 - for cx := 0; cx < size; cx++ { 468 - // Calculate distance from center 469 - dx := float64(cx - center) 470 - dy := float64(cy - center) 471 - distance := math.Sqrt(dx*dx + dy*dy) 472 - 473 - // Only draw pixels within the circle 474 - if distance <= radius { 475 - circle.Set(cx, cy, scaledImg.At(cx, cy)) 476 - } 477 - } 478 - } 479 - 480 - // Draw the circle onto the card 481 - bounds := c.Img.Bounds() 482 - destRect := image.Rect(x, y, x+size, y+size) 483 - 484 - // Make sure we don't draw outside the card bounds 485 - if destRect.Max.X > bounds.Max.X { 486 - destRect.Max.X = bounds.Max.X 487 - } 488 - if destRect.Max.Y > bounds.Max.Y { 489 - destRect.Max.Y = bounds.Max.Y 490 - } 491 - 492 - draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over) 493 - 494 - return nil 495 - } 496 - 497 - // DrawRect draws a rect with the given color 498 - func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) { 499 - draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src) 500 - }
···
-1
appview/state/router.go
··· 35 router.Get("/favicon.svg", s.Favicon) 36 router.Get("/favicon.ico", s.Favicon) 37 router.Get("/pwa-manifest.json", s.PWAManifest) 38 - router.Get("/pwa-icon.png", s.PWAIcon) 39 router.Get("/robots.txt", s.RobotsTxt) 40 41 userRouter := s.UserRouter(&middleware)
··· 35 router.Get("/favicon.svg", s.Favicon) 36 router.Get("/favicon.ico", s.Favicon) 37 router.Get("/pwa-manifest.json", s.PWAManifest) 38 router.Get("/robots.txt", s.RobotsTxt) 39 40 userRouter := s.UserRouter(&middleware)
+15 -110
appview/state/state.go
··· 1 package state 2 3 import ( 4 - "bytes" 5 "context" 6 "database/sql" 7 "errors" 8 "fmt" 9 - "image" 10 - "image/color" 11 - "image/draw" 12 - "image/png" 13 "log/slog" 14 "net/http" 15 - "strconv" 16 "strings" 17 "time" 18 ··· 26 dbnotify "tangled.org/core/appview/notify/db" 27 phnotify "tangled.org/core/appview/notify/posthog" 28 "tangled.org/core/appview/oauth" 29 - "tangled.org/core/appview/ogcard" 30 "tangled.org/core/appview/pages" 31 "tangled.org/core/appview/reporesolver" 32 "tangled.org/core/appview/validator" ··· 46 securejoin "github.com/cyphar/filepath-securejoin" 47 "github.com/go-chi/chi/v5" 48 "github.com/posthog/posthog-go" 49 - "github.com/srwiley/rasterx" 50 ) 51 52 type State struct { ··· 229 230 // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 231 const manifestJson = `{ 232 - "name": "tangled", 233 - "description": "tightly-knit social coding.", 234 - "start_url": "/", 235 - "id": "org.tangled", 236 - "display": "standalone", 237 - "background_color": "#111827", 238 - "theme_color": "#111827", 239 - "icons": [ 240 - { 241 - "src": "/pwa-icon.png?res=512&transparent=true", 242 - "type": "image/png", 243 - "sizes": "512x512", 244 - "purpose": "any monchrome" 245 - }, 246 - { 247 - "src": "/pwa-icon.png?res=512", 248 - "type": "image/png", 249 - "sizes": "512x512", 250 - "purpose": "maskable" 251 - }, 252 - { 253 - "src": "/pwa-icon.png?res=192", 254 - "type": "image/png", 255 - "sizes": "192x192", 256 - "purpose": "maskable" 257 - }, 258 - { 259 - "src": "/pwa-icon.png?res=144", 260 - "type": "image/png", 261 - "sizes": "144x144", 262 - "purpose": "maskable" 263 - }, 264 - { 265 - "src": "/pwa-icon.png?res=96", 266 - "type": "image/png", 267 - "sizes": "96x96", 268 - "purpose": "maskable" 269 - }, 270 - { 271 - "src": "/pwa-icon.png?res=72", 272 - "type": "image/png", 273 - "sizes": "72x72", 274 - "purpose": "maskable" 275 - }, 276 - { 277 - "src": "/pwa-icon.png?res=48", 278 - "type": "image/png", 279 - "sizes": "48x48", 280 - "purpose": "maskable" 281 - } 282 - ] 283 }` 284 285 - func (s *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 286 w.Header().Set("Content-Type", "application/json") 287 w.Write([]byte(manifestJson)) 288 - } 289 - 290 - 291 - func (s *State) PWAIcon(w http.ResponseWriter, r *http.Request) { 292 - tangledBgColour := color.RGBA{0x11, 0x18, 0x27, 0xff} 293 - etag := "W/\"dolly-logo-v1 " + r.URL.Query().Get("res") + r.URL.Query().Get("transparent") + "\"" 294 - 295 - w.Header().Set("Content-Type", "image/png") 296 - w.Header().Set("Cache-Control", "public, max-age=604800, stale-while-revalidate=86400, stale-if-error=86400") 297 - w.Header().Set("Etag", etag) 298 - // if the client already has the same logo dont bother recreating it 299 - if len(r.Header["If-None-Match"]) == 1 && r.Header["If-None-Match"][0] == etag { 300 - w.WriteHeader(304) 301 - return 302 - } 303 - 304 - icon, err := ogcard.BuildSVGIconFromPath("templates/fragments/dolly/logo.svg", tangledBgColour) 305 - // ignore error as icon is default icon on error 306 - // also this should not fail 307 - 308 - transparent := r.URL.Query().Get("transparent") == "true"; 309 - size, err := strconv.Atoi(r.URL.Query().Get("res")) 310 - if err != nil { 311 - size = 512 312 - } 313 - 314 - // maskable safe area is centered circle with radius 40% 315 - // area outside may be cropped so make sure the icon fills it 316 - // bc trig, safe square is just over 70% of screen 317 - icon.SetTarget(float64(size) * 0.15, float64(size) * 0.15, float64(size) * 0.7, float64(size) * 0.7) 318 - rgba := image.NewRGBA(image.Rect(0, 0, size, size)) 319 - 320 - // set image bg 321 - var bgColour color.Color = tangledBgColour 322 - if transparent { 323 - bgColour = color.Transparent 324 - } 325 - draw.Draw(rgba, rgba.Bounds(), &image.Uniform{ bgColour }, image.Point{}, draw.Src) 326 - 327 - // Create a scanner and rasterize the SVG 328 - scanner := rasterx.NewScannerGV(size, size, rgba, rgba.Bounds()) 329 - raster := rasterx.NewDasher(size, size, scanner) 330 - 331 - icon.Draw(raster, 1.0) 332 - 333 - var img_buff bytes.Buffer 334 - err = png.Encode(&img_buff, rgba) 335 - // ignore error as encoding shouldnt fail 336 - // if it fails itll return no bytes which is fine 337 - w.Write(img_buff.Bytes()) 338 } 339 340 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
··· 1 package state 2 3 import ( 4 "context" 5 "database/sql" 6 "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 "strings" 11 "time" 12 ··· 20 dbnotify "tangled.org/core/appview/notify/db" 21 phnotify "tangled.org/core/appview/notify/posthog" 22 "tangled.org/core/appview/oauth" 23 "tangled.org/core/appview/pages" 24 "tangled.org/core/appview/reporesolver" 25 "tangled.org/core/appview/validator" ··· 39 securejoin "github.com/cyphar/filepath-securejoin" 40 "github.com/go-chi/chi/v5" 41 "github.com/posthog/posthog-go" 42 ) 43 44 type State struct { ··· 221 222 // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 223 const manifestJson = `{ 224 + "name": "tangled", 225 + "description": "tightly-knit social coding.", 226 + "icons": [ 227 + { 228 + "src": "/favicon.svg", 229 + "sizes": "144x144" 230 + } 231 + ], 232 + "start_url": "/", 233 + "id": "org.tangled", 234 + 235 + "display": "standalone", 236 + "background_color": "#111827", 237 + "theme_color": "#111827" 238 }` 239 240 + func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 241 w.Header().Set("Content-Type", "application/json") 242 w.Write([]byte(manifestJson)) 243 } 244 245 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
-1
nix/pkgs/appview-static-files.nix
··· 17 (allow file-read* (subpath "/System/Library/OpenSSL")) 18 ''; 19 } '' 20 - #!/bin/bash 21 mkdir -p $out/{fonts,icons} && cd $out 22 cp -f ${htmx-src} htmx.min.js 23 cp -f ${htmx-ws-src} htmx-ext-ws.min.js
··· 17 (allow file-read* (subpath "/System/Library/OpenSSL")) 18 ''; 19 } '' 20 mkdir -p $out/{fonts,icons} && cd $out 21 cp -f ${htmx-src} htmx.min.js 22 cp -f ${htmx-ws-src} htmx-ext-ws.min.js