Skip to content

Commit

Permalink
✨ feat: add file extension support for paste URLs and content handling
Browse files Browse the repository at this point in the history
The commit adds support for file extensions in paste URLs, improving the user
experience and content handling. Key changes include:

1. Automatic extension detection from filenames
2. Extension-aware URL generation
3. Enhanced content type handling for images and text
4. Improved route handling for URLs with extensions
  • Loading branch information
watzon committed Nov 12, 2024
1 parent 0175136 commit 1f8ec39
Show file tree
Hide file tree
Showing 7 changed files with 228 additions and 141 deletions.
31 changes: 25 additions & 6 deletions internal/models/paste.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package models

import (
"fmt"
"strings"
"time"

"github.com/gofiber/fiber/v2"
Expand Down Expand Up @@ -42,13 +43,26 @@ type Paste struct {
// BeforeCreate generates ID and DeleteKey if not set
func (p *Paste) BeforeCreate(tx *gorm.DB) error {
if p.ID == "" {
p.ID = utils.GenerateID(8) // We'll implement this in utils
p.ID = utils.GenerateID(8)
}
if p.DeleteKey == "" {
p.DeleteKey = utils.GenerateID(32)
}

// If StorageName is not set, find the default storage config
// Handle file extension
// If Extension is not explicitly set, try to get it from filename
if p.Extension == "" && p.Filename != "" {
// Split filename by dots and get the last part
parts := strings.Split(p.Filename, ".")
if len(parts) > 1 {
p.Extension = parts[len(parts)-1]
}
}

// Clean the extension (remove any leading dots and whitespace)
p.Extension = strings.TrimSpace(strings.TrimPrefix(p.Extension, "."))

// Storage configuration handling
if p.StorageName == "" {
var cfg config.Config
if err := tx.Statement.Context.Value("config").(*config.Config); err != nil {
Expand Down Expand Up @@ -83,10 +97,15 @@ func (p *Paste) ToResponse() fiber.Map {
"private": p.Private,
}

// Add URL paths
response["url"] = fmt.Sprintf("/%s", p.ID)
response["raw_url"] = fmt.Sprintf("/raw/%s", p.ID)
response["download_url"] = fmt.Sprintf("/download/%s", p.ID)
// Add URL paths with extension if available
urlSuffix := p.ID
if p.Extension != "" {
urlSuffix = p.ID + "." + p.Extension
}

response["url"] = fmt.Sprintf("/%s", urlSuffix)
response["raw_url"] = fmt.Sprintf("/raw/%s", urlSuffix)
response["download_url"] = fmt.Sprintf("/download/%s", urlSuffix)

// Only include delete_url if there's a delete key
if p.DeleteKey != "" {
Expand Down
252 changes: 149 additions & 103 deletions internal/server/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -720,38 +720,41 @@ func (s *Server) handleView(c *fiber.Ctx) error {
return err
}

id := c.Params("id")

// Try shortlink first
if shortlink, err := s.findShortlink(id); err == nil {
// Log initial state
s.logger.Info("shortlink found",
zap.String("id", id),
zap.String("original_url", shortlink.TargetURL))

// Update click stats asynchronously
go s.updateShortlinkStats(shortlink, c)
id := getPasteID(c)
hasExtension := c.Params("ext") != ""

// Only check for shortlink if there's no extension
if !hasExtension {
if shortlink, err := s.findShortlink(id); err == nil {
// Log initial state
s.logger.Info("shortlink found",
zap.String("id", id),
zap.String("original_url", shortlink.TargetURL))

// Clean and validate the URL
targetURL := strings.TrimSpace(shortlink.TargetURL)
s.logger.Info("cleaned url",
zap.String("id", id),
zap.String("cleaned_url", targetURL))
// Update click stats asynchronously
go s.updateShortlinkStats(shortlink)

// Ensure URL has a protocol
if !strings.Contains(targetURL, "://") {
targetURL = "https://" + targetURL
s.logger.Info("added protocol",
// Clean and validate the URL
targetURL := strings.TrimSpace(shortlink.TargetURL)
s.logger.Info("cleaned url",
zap.String("id", id),
zap.String("final_url", targetURL))
}
zap.String("cleaned_url", targetURL))

// Ensure URL has a protocol
if !strings.Contains(targetURL, "://") {
targetURL = "https://" + targetURL
s.logger.Info("added protocol",
zap.String("id", id),
zap.String("final_url", targetURL))
}

// Log final redirect attempt
s.logger.Info("attempting redirect",
zap.String("id", id),
zap.String("redirect_url", targetURL))
// Log final redirect attempt
s.logger.Info("attempting redirect",
zap.String("id", id),
zap.String("redirect_url", targetURL))

return c.Redirect(targetURL, fiber.StatusFound)
return c.Redirect(targetURL, fiber.StatusFound)
}
}

// Try paste
Expand All @@ -763,11 +766,116 @@ func (s *Server) handleView(c *fiber.Ctx) error {
c.Set("Cache-Control", "public, max-age=300") // Cache for 5 minutes

// Handle view based on content type
if isTextContent(paste.MimeType) {
switch {
case isTextContent(paste.MimeType):
return s.renderPasteView(c, paste)
case isImageContent(paste.MimeType):
return s.renderRawContent(c, paste)
default:
return c.Redirect("/download/"+id, fiber.StatusTemporaryRedirect)
}
}

// renderPasteView renders the paste view for text content
// Includes syntax highlighting using Chroma
// Supports language detection and line numbering
func (s *Server) renderPasteView(c *fiber.Ctx, paste *models.Paste) error {
// Get content from storage
store, err := s.storage.GetStore(paste.StorageName)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Storage not found")
}

return c.Redirect("/download/"+id, fiber.StatusTemporaryRedirect)
content, err := store.Get(paste.StoragePath)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to read content")
}
defer content.Close()

// Read all content
data, err := io.ReadAll(content)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to read content")
}

// Get lexer based on extension or mime type
lexer := lexers.Get(paste.Extension)
if lexer == nil {
// Try to match by filename
lexer = lexers.Match(paste.Filename)
if lexer == nil {
// Try to analyze content
lexer = lexers.Analyse(string(data))
if lexer == nil {
lexer = lexers.Fallback
}
}
}
lexer = chroma.Coalesce(lexer)

// Create formatter without classes (will use inline styles)
formatter := html.New(
html.WithLineNumbers(true),
html.WithLinkableLineNumbers(true, ""),
html.TabWidth(4),
)

// Use gruvbox style (dark theme that matches our UI)
style := styles.Get("gruvbox")
if style == nil {
style = styles.Fallback
}

// Generate highlighted HTML
var highlightedContent strings.Builder
iterator, err := lexer.Tokenise(nil, string(data))
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to tokenize content")
}

err = formatter.Format(&highlightedContent, style, iterator)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to format content")
}

return c.Render("paste", fiber.Map{
"id": paste.ID,
"filename": paste.Filename,
"content": highlightedContent.String(),
"language": lexer.Config().Name,
"created": paste.CreatedAt.Format(time.RFC3339),
"expires": paste.ExpiresAt,
"baseUrl": s.config.Server.BaseURL,
}, "layouts/main")
}

// renderRawContent serves the raw content with proper content type
// Used for displaying images and other browser-viewable content
func (s *Server) renderRawContent(c *fiber.Ctx, paste *models.Paste) error {
// Get the correct store for this paste
store, err := s.storage.GetStore(paste.StorageName)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Storage not found")
}

// Get content from storage
content, err := store.Get(paste.StoragePath)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to read content")
}
defer content.Close()

// Set appropriate headers
c.Set("Content-Type", paste.MimeType)
c.Set("Content-Length", fmt.Sprintf("%d", paste.Size))

// Read and send the content
data, err := io.ReadAll(content)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to read content")
}

return c.Send(data)
}

// handleRawView serves the raw content of a paste
Expand All @@ -778,7 +886,7 @@ func (s *Server) handleRawView(c *fiber.Ctx) error {
return err
}

id := c.Params("id")
id := getPasteID(c)

paste, err := s.findPaste(id)
if err != nil {
Expand Down Expand Up @@ -835,7 +943,7 @@ func (s *Server) handleDownload(c *fiber.Ctx) error {
return err
}

id := c.Params("id")
id := getPasteID(c)

paste, err := s.findPaste(id)
if err != nil {
Expand Down Expand Up @@ -914,78 +1022,6 @@ func (s *Server) handleDeleteWithKey(c *fiber.Ctx) error {

// Helper Functions

// renderPasteView renders the paste view for text content
// Includes syntax highlighting using Chroma
// Supports language detection and line numbering
func (s *Server) renderPasteView(c *fiber.Ctx, paste *models.Paste) error {
// Get content from storage
store, err := s.storage.GetStore(paste.StorageName)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Storage not found")
}

content, err := store.Get(paste.StoragePath)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to read content")
}
defer content.Close()

// Read all content
data, err := io.ReadAll(content)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to read content")
}

// Get lexer based on extension or mime type
lexer := lexers.Get(paste.Extension)
if lexer == nil {
// Try to match by filename
lexer = lexers.Match(paste.Filename)
if lexer == nil {
// Try to analyze content
lexer = lexers.Analyse(string(data))
if lexer == nil {
lexer = lexers.Fallback
}
}
}
lexer = chroma.Coalesce(lexer)

// Create formatter without classes (will use inline styles)
formatter := html.New(
html.WithLineNumbers(true),
html.WithLinkableLineNumbers(true, ""),
html.TabWidth(4),
)

// Use gruvbox style (dark theme that matches our UI)
style := styles.Get("gruvbox")
if style == nil {
style = styles.Fallback
}

// Generate highlighted HTML
var highlightedContent strings.Builder
iterator, err := lexer.Tokenise(nil, string(data))
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to tokenize content")
}

err = formatter.Format(&highlightedContent, style, iterator)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to format content")
}

return c.Render("paste", fiber.Map{
"id": paste.ID,
"filename": paste.Filename,
"content": highlightedContent.String(),
"language": lexer.Config().Name,
"created": paste.CreatedAt.Format(time.RFC3339),
"expires": paste.ExpiresAt,
}, "layouts/main")
}

// getStorageSize returns total size of stored files in bytes
// Calculated as sum of all paste sizes in database
func (s *Server) getStorageSize() uint64 {
Expand All @@ -1012,3 +1048,13 @@ func (s *Server) addBaseURLToPasteResponse(response fiber.Map) {
}
}
}

// Helper function to extract paste ID from params
func getPasteID(c *fiber.Ctx) string {
id := c.Params("id")
// If the ID includes an extension, remove it
if ext := c.Params("ext"); ext != "" {
return strings.TrimSuffix(id, "."+ext)
}
return id
}
2 changes: 1 addition & 1 deletion internal/server/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ func (s *Server) findShortlink(id string) (*models.Shortlink, error) {

// updateShortlinkStats increments the click count and updates last click time
// for a given shortlink
func (s *Server) updateShortlinkStats(shortlink *models.Shortlink, c *fiber.Ctx) {
func (s *Server) updateShortlinkStats(shortlink *models.Shortlink) {
now := time.Now()
s.db.Model(shortlink).Updates(map[string]interface{}{
"clicks": gorm.Expr("clicks + 1"),
Expand Down
3 changes: 3 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,11 @@ func (s *Server) SetupRoutes() {
s.app.Get("/stats", s.handleStats)
s.app.Post("/", s.auth.Auth(false), s.handleUpload)
s.app.All("/delete/:id/:key", s.handleDeleteWithKey)
s.app.Get("/download/:id.:ext", s.handleDownload)
s.app.Get("/download/:id", s.handleDownload)
s.app.Get("/raw/:id.:ext", s.handleRawView)
s.app.Get("/raw/:id", s.handleRawView)
s.app.Get("/:id.:ext", s.handleView)
s.app.Get("/:id", s.handleView)
}

Expand Down
4 changes: 2 additions & 2 deletions views/docs.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

<h1>Paste69 API Documentation</h1>

<h2>File Upload Endpoints</h2>

<section>
<h2>File Upload Endpoints</h2>

<strong>1. Multipart Upload</strong>
<div class="code-block">
<code>POST {{baseUrl}}</code>
Expand Down
4 changes: 4 additions & 0 deletions views/paste.hbs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
<div class="nav-bar">
<a href="{{baseUrl}}" class="nav-link">cd ..</a>
</div>

<div class="paste-header">
<div class="paste-info">
<h2>{{filename}}</h2>
Expand Down
Loading

0 comments on commit 1f8ec39

Please sign in to comment.