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 --}}