diff --git a/go.mod b/go.mod index 476fa37..5b6e1f3 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,12 @@ require ( require github.com/watzon/hdur v1.0.0 +require ( + github.com/fogleman/gg v1.3.0 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + golang.org/x/image v0.22.0 // indirect +) + require ( github.com/andybalholm/brotli v1.1.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 // indirect diff --git a/go.sum b/go.sum index b963a2f..eaeec11 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,8 @@ github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yA github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= @@ -79,6 +81,8 @@ github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM= github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0= github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y= github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= @@ -201,6 +205,8 @@ golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo= golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= +golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g= +golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= diff --git a/internal/server/handlers/paste.go b/internal/server/handlers/paste.go index 9fbc9e5..1777cfc 100644 --- a/internal/server/handlers/paste.go +++ b/internal/server/handlers/paste.go @@ -107,3 +107,20 @@ func (h *PasteHandlers) HandleDeletePaste(c *fiber.Ctx) error { func (h *PasteHandlers) HandleUpdateExpiration(c *fiber.Ctx) error { return h.services.Paste.UpdateExpiration(c, getPasteID(c)) } + +// HandleGetPasteImage returns an image of the paste suitable for Open Graph +func (h *PasteHandlers) HandleGetPasteImage(c *fiber.Ctx) error { + id := getPasteID(c) + + // Get extension from locals if available + if ext := c.Locals("extension"); ext != nil { + id = id + "." + ext.(string) + } + + paste, err := h.services.Paste.GetPaste(id) + if err != nil { + return err + } + + return h.services.Paste.GetPasteImage(c, paste) +} diff --git a/internal/server/server.go b/internal/server/server.go index 4d87771..486e732 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -148,11 +148,16 @@ func (s *Server) SetupRoutes() { c.Locals("extension", c.Params("ext")) return s.handlers.Paste.HandleDownload(c) }) + s.app.Get("/p/:id.:ext/image", func(c *fiber.Ctx) error { + c.Locals("extension", c.Params("ext")) + return s.handlers.Paste.HandleGetPasteImage(c) + }) // Handle paste routes without extensions s.app.Get("/p/:id/raw", s.handlers.Paste.HandleRawView) s.app.Get("/p/:id/download", s.handlers.Paste.HandleDownload) s.app.Delete("/p/:id/:key", s.handlers.Paste.HandleDeleteWithKey) + s.app.Get("/p/:id/image", s.handlers.Paste.HandleGetPasteImage) // Handle URL redirects s.app.Get("/u/:id", s.handlers.URL.HandleRedirect) diff --git a/internal/server/services/ogimage.go b/internal/server/services/ogimage.go new file mode 100644 index 0000000..9c7efde --- /dev/null +++ b/internal/server/services/ogimage.go @@ -0,0 +1,373 @@ +package services + +import ( + "bytes" + "fmt" + "image" + "image/color" + "image/png" + "path/filepath" + "strings" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" + "github.com/fogleman/gg" +) + +const ( + // OpenGraph recommended image dimensions + ogImageWidth = 1200 + ogImageHeight = 630 + + // Text settings + maxLines = 25 + fontSize = 20 + lineSpacing = 1.5 + padding = 3.5 + borderWidth = 2.0 + borderRadius = 15.0 + + // Line number settings + lineNumPadding = 10.0 + lineNumWidth = 50.0 + lineNumColor = 0x666666 + + // Font settings + fontPath = "public/fonts/Go-Mono.ttf" +) + +// WatermarkConfig holds configuration for the image watermark +type WatermarkConfig struct { + Enabled bool + Text string + FontSize float64 + Color color.Color + PaddingX float64 + PaddingY float64 + FontPath string +} + +// DefaultWatermarkConfig returns the default watermark configuration +func DefaultWatermarkConfig() WatermarkConfig { + return WatermarkConfig{ + Enabled: true, + Text: "0x45", + FontSize: 36, + Color: color.RGBA{128, 128, 128, 80}, // Semi-transparent gray + PaddingX: 20, + PaddingY: 20, + FontPath: fontPath, + } +} + +// ImageConfig holds all configuration for image generation +type ImageConfig struct { + Width int + Height int + MaxLines int + FontSize float64 + LineSpacing float64 + Padding float64 + BorderWidth float64 + BorderRadius float64 + FontPath string + Watermark WatermarkConfig +} + +// DefaultImageConfig returns the default image configuration +func DefaultImageConfig() ImageConfig { + return ImageConfig{ + Width: ogImageWidth, + Height: ogImageHeight, + MaxLines: maxLines, + FontSize: fontSize, + LineSpacing: lineSpacing, + Padding: padding, + BorderWidth: borderWidth, + BorderRadius: borderRadius, + FontPath: fontPath, + Watermark: DefaultWatermarkConfig(), + } +} + +// wordWrap wraps text at the specified width +func wordWrap(text string, dc *gg.Context, maxWidth float64) []string { + var lines []string + words := strings.Fields(text) + if len(words) == 0 { + return []string{text} + } + + currentLine := words[0] + + for _, word := range words[1:] { + width, _ := dc.MeasureString(currentLine + " " + word) + if width <= maxWidth { + currentLine += " " + word + } else { + lines = append(lines, currentLine) + currentLine = word + } + } + lines = append(lines, currentLine) + return lines +} + +type codeImageContext struct { + dc *gg.Context + style *chroma.Style + textStartX float64 + maxWidth float64 + lineHeight float64 + currentLine int +} + +// setupCanvas initializes the drawing context with background and border +func setupCanvas(width, height int) (*gg.Context, error) { + dc := gg.NewContext(width, height) + dc.Clear() + + // Create clipping path for rounded corners + dc.DrawRoundedRectangle(borderWidth/2, borderWidth/2, + float64(width)-borderWidth, + float64(height)-borderWidth, + borderRadius) + dc.Clip() + + // Set background color (dark theme) + dc.SetColor(color.RGBA{40, 44, 52, 255}) + dc.DrawRectangle(borderWidth/2, borderWidth/2, + float64(width)-borderWidth, + float64(height)-borderWidth) + dc.Fill() + + // Reset clip and draw border + dc.ResetClip() + dc.SetColor(color.RGBA{60, 64, 72, 255}) + dc.SetLineWidth(borderWidth) + dc.DrawRoundedRectangle(borderWidth/2, borderWidth/2, + float64(width)-borderWidth, + float64(height)-borderWidth, + borderRadius) + dc.Stroke() + + return dc, nil +} + +// setupSyntaxHighlighting prepares the lexer and style for syntax highlighting +func setupSyntaxHighlighting(code string) (chroma.Iterator, *chroma.Style, error) { + lexer := lexers.Analyse(code) + if lexer == nil { + lexer = lexers.Get("text") + } + if lexer == nil { + lexer = lexers.Fallback + } + lexer = chroma.Coalesce(lexer) + + style := styles.Get("monokai") + if style == nil { + style = styles.Fallback + } + + iterator, err := lexer.Tokenise(nil, code) + if err != nil { + return nil, nil, err + } + + return iterator, style, nil +} + +// drawLineNumbers draws the line number column and separator +func drawLineNumbers(ctx *codeImageContext, y float64) { + // Draw line number + ctx.dc.SetColor(color.RGBA{102, 102, 102, 255}) + lineNumX := padding + borderWidth + lineNumWidth - 5 + ctx.dc.DrawStringAnchored(fmt.Sprintf("%d", ctx.currentLine), lineNumX, y, 1.0, 0) +} + +// getTokenColor extracts the color for a token based on its style +func getTokenColor(token chroma.Token, style *chroma.Style) color.Color { + entry := style.Get(token.Type) + if entry.IsZero() { + entry = style.Get(token.Type.SubCategory()) + } + if entry.IsZero() { + entry = style.Get(token.Type.Category()) + } + + if !entry.IsZero() && entry.Colour != 0 { + hexColor := entry.Colour.String() + if strings.HasPrefix(hexColor, "#") { + hexColor = hexColor[1:] + } + var r, g, b uint8 + if len(hexColor) == 6 { + fmt.Sscanf(hexColor, "%02x%02x%02x", &r, &g, &b) + return color.RGBA{r, g, b, 255} + } + } + return color.White +} + +// drawLineContent draws a single line of text with proper wrapping +func drawLineContent(ctx *codeImageContext, line string, tokenColor color.Color, x *float64, y *float64) { + if *x == ctx.textStartX && len(line) > 0 { + wrappedLines := wordWrap(line, ctx.dc, ctx.maxWidth) + for j, wrappedLine := range wrappedLines { + if j > 0 { + *y += ctx.lineHeight + ctx.currentLine++ + drawLineNumbers(ctx, *y) + ctx.dc.SetColor(tokenColor) + } + ctx.dc.DrawString(wrappedLine, *x, *y) + if j < len(wrappedLines)-1 { + *x = ctx.textStartX + } else { + width, _ := ctx.dc.MeasureString(wrappedLine) + *x += width + } + } + } else { + ctx.dc.DrawString(line, *x, *y) + width, _ := ctx.dc.MeasureString(line) + *x += width + } +} + +func GenerateCodeImage(code string) ([]byte, error) { + img, err := GenerateCodeImageWithConfig(code, "", DefaultImageConfig()) + if err != nil { + return nil, err + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, fmt.Errorf("failed to encode image: %w", err) + } + return buf.Bytes(), nil +} + +func GenerateCodeImageWithConfig(code, language string, config ImageConfig) (image.Image, error) { + // Setup canvas + dc, err := setupCanvas(config.Width, config.Height) + if err != nil { + return nil, err + } + + // Setup syntax highlighting + iterator, style, err := setupSyntaxHighlighting(code) + if err != nil { + return nil, err + } + + // Load font + fontAbsPath, err := filepath.Abs(config.FontPath) + if err != nil { + return nil, err + } + if err := dc.LoadFontFace(fontAbsPath, config.FontSize); err != nil { + return nil, err + } + + // Calculate dimensions + textStartX := padding + borderWidth + lineNumWidth + lineNumPadding + maxTextWidth := float64(config.Width) - textStartX - padding - borderWidth + + // Create context + ctx := &codeImageContext{ + dc: dc, + style: style, + textStartX: textStartX, + maxWidth: maxTextWidth, + lineHeight: config.FontSize * lineSpacing, + currentLine: 1, + } + + // Draw separator line for line numbers + dc.SetColor(color.RGBA{60, 64, 72, 255}) + dc.SetLineWidth(1) + dc.DrawLine( + padding+borderWidth+lineNumWidth, + borderWidth, + padding+borderWidth+lineNumWidth, + float64(config.Height)-borderWidth, + ) + dc.Stroke() + + // Draw code + x := textStartX + y := padding + config.FontSize + borderWidth + + for _, token := range iterator.Tokens() { + if ctx.currentLine > config.MaxLines { + break + } + + tokenColor := getTokenColor(token, style) + lines := strings.Split(token.Value, "\n") + + for i, line := range lines { + if ctx.currentLine > config.MaxLines { + break + } + + drawLineNumbers(ctx, y) + + if i > 0 { + x = textStartX + } + + if x > textStartX { + remainingWidth := float64(config.Width) - x - padding - borderWidth + width, _ := dc.MeasureString(line) + if width > remainingWidth { + x = textStartX + y += ctx.lineHeight + ctx.currentLine++ + } + } + + dc.SetColor(tokenColor) + drawLineContent(ctx, line, tokenColor, &x, &y) + + if i < len(lines)-1 { + y += ctx.lineHeight + ctx.currentLine++ + x = textStartX + } + } + } + + if ctx.currentLine > config.MaxLines { + dc.SetColor(color.White) + dc.DrawString("...", textStartX, y+ctx.lineHeight) + } + + // Add watermark + if config.Watermark.Enabled { + if err := drawWatermark(dc, config.Watermark); err != nil { + return nil, fmt.Errorf("failed to draw watermark: %w", err) + } + } + + return dc.Image(), nil +} + +func drawWatermark(dc *gg.Context, config WatermarkConfig) error { + if err := dc.LoadFontFace(config.FontPath, config.FontSize); err != nil { + return fmt.Errorf("failed to load watermark font: %w", err) + } + + dc.SetColor(config.Color) + textWidth, _ := dc.MeasureString(config.Text) + + // Position in bottom right corner + x := float64(dc.Width()) - textWidth - config.PaddingX + y := float64(dc.Height()) - config.PaddingY + + // Draw the text + dc.DrawString(config.Text, x, y) + return nil +} diff --git a/internal/server/services/paste.go b/internal/server/services/paste.go index a0181cd..8963769 100644 --- a/internal/server/services/paste.go +++ b/internal/server/services/paste.go @@ -170,6 +170,40 @@ func (s *PasteService) GetPaste(id string) (*models.Paste, error) { return &paste, nil } +// GetPasteImage returns an image of the paste suitable for Open Graph +func (s *PasteService) GetPasteImage(c *fiber.Ctx, paste *models.Paste) error { + // First check if the paste is even text, if not we won't generate an image + if !s.isTextContent(paste.MimeType) { + s.logger.Debug("Cannot generate image for non-text content", + zap.String("mime_type", paste.MimeType), + zap.String("id", paste.ID)) + return fiber.NewError(fiber.StatusBadRequest, "Cannot generate image for non-text content") + } + + // Get the content + content, err := s.storage.Get(paste.StoragePath) + if err != nil { + s.logger.Error("Failed to get paste content for image generation", + zap.Error(err), + zap.String("id", paste.ID), + zap.String("storage_path", paste.StoragePath)) + return err + } + + // Generate the image + image, err := GenerateCodeImage(string(content)) + if err != nil { + s.logger.Error("Failed to generate paste image", + zap.Error(err), + zap.String("id", paste.ID)) + return err + } + + c.Set("Cache-Control", "max-age=31536000, immutable") + c.Set("Content-Type", "image/png") + return c.Send(image) +} + // RenderPaste renders the paste view for text content func (s *PasteService) RenderPaste(c *fiber.Ctx, paste *models.Paste) error { if s.isTextContent(paste.MimeType) { @@ -528,13 +562,15 @@ func (s *PasteService) renderPasteView(c *fiber.Ctx, paste *models.Paste) error } return c.Render("paste", fiber.Map{ - "id": pasteID, - "filename": paste.Filename, - "created": paste.CreatedAt.Format("2006-01-02 15:04:05"), - "expires": formatExpiryTime(paste.ExpiresAt), - "language": lexer.Config().Name, - "content": codeBuffer.String(), - "baseUrl": s.config.Server.BaseURL, + "isPaste": true, + "id": pasteID, + "filename": paste.Filename, + "extension": paste.Extension, + "created": paste.CreatedAt.Format("2006-01-02 15:04:05"), + "expires": formatExpiryTime(paste.ExpiresAt), + "language": lexer.Config().Name, + "content": codeBuffer.String(), + "baseUrl": s.config.Server.BaseURL, "metadata": fiber.Map{ "size": formatSize(paste.Size), "mimeType": paste.MimeType, diff --git a/public/fonts/Go-Mono.ttf b/public/fonts/Go-Mono.ttf new file mode 100644 index 0000000..853d473 Binary files /dev/null and b/public/fonts/Go-Mono.ttf differ diff --git a/public/images/0x45-logo.png b/public/images/0x45-logo.png new file mode 100644 index 0000000..a716a71 Binary files /dev/null and b/public/images/0x45-logo.png differ diff --git a/public/images/0x45-logo.svg b/public/images/0x45-logo.svg new file mode 100644 index 0000000..7c28a22 --- /dev/null +++ b/public/images/0x45-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/views/partials/head.hbs b/views/partials/head.hbs index c693ac6..a9eb858 100644 --- a/views/partials/head.hbs +++ b/views/partials/head.hbs @@ -8,7 +8,11 @@ +{{#if isPaste}} + +{{else}} +{{/if}} {{!-- Custom head content can be added here --}}