Skip to content

Commit

Permalink
Merge branch 'main' of github.com:watzon/0x45
Browse files Browse the repository at this point in the history
  • Loading branch information
watzon committed Nov 13, 2024
2 parents 59da60b + 870c7f0 commit b9e1ef2
Show file tree
Hide file tree
Showing 9 changed files with 722 additions and 99 deletions.
36 changes: 18 additions & 18 deletions config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ storage:
path: ./uploads
default: true

- name: s3
type: s3
s3_bucket: paste69-bucket
s3_region: us-east-1
s3_key: your-access-key
s3_secret: your-secret-key
s3_endpoint: https://localhost.localstack.cloud:4566
# - name: s3
# type: s3
# s3_bucket: paste69-bucket
# s3_region: us-east-1
# s3_key: your-access-key
# s3_secret: your-secret-key
# s3_endpoint: https://localhost.localstack.cloud:4566

# Server configuration
server:
Expand Down Expand Up @@ -57,7 +57,7 @@ server:
burst: 5 # Maximum burst size

# Use Redis for rate limiting if it's available (required for prefork)
use_redis: true
use_redis: false

# How often to clean up inactive IP limiters
ip_cleanup_interval: 1h
Expand Down Expand Up @@ -97,17 +97,9 @@ smtp:
starttls: false
tls_verify: false

# Feature flags
features:
url_shortener: true
file_preview: true
syntax_highlighting: true
api_keys: true
base_url: http://localhost:3000

# Redis configuration
redis:
enabled: true
enabled: false
address: localhost:6379
password: ""
db: 0
Expand All @@ -120,4 +112,12 @@ retention:
with_key:
min_age: 30.0 # Minimum retention in days
max_age: 730.0 # Maximum retention in days (2 years)
points: 50 # Number of points to generate for the curve
points: 50 # Number of points to generate for the curve

# Feature flags (TODO)
features:
url_shortener: true
file_preview: true
syntax_highlighting: true
api_keys: true
base_url: http://localhost:3000
4 changes: 2 additions & 2 deletions internal/database/migrations/000002_create_api_keys.up.sql
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ CREATE TABLE api_keys (
max_expiration VARCHAR(32) DEFAULT '24h',
rate_limit INTEGER DEFAULT 100,
allow_private BOOLEAN DEFAULT TRUE,
allow_updates BOOLEAN DEFAULT FALSE,
allow_updates BOOLEAN DEFAULT TRUE,

email VARCHAR(255),
name VARCHAR(255),

last_used_at TIMESTAMP,
usage_count BIGINT DEFAULT 0,

allow_shortlinks BOOLEAN DEFAULT FALSE,
allow_shortlinks BOOLEAN DEFAULT TRUE,
shortlink_quota INTEGER DEFAULT 0,
shortlink_prefix VARCHAR(16)
);
Expand Down
268 changes: 254 additions & 14 deletions internal/server/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,38 +54,133 @@ func (s *Server) handleStats(c *fiber.Ctx) error {
s.db.Model(&models.Paste{}).Count(&totalPastes)
s.db.Model(&models.Shortlink{}).Count(&totalUrls)

// Get historical data
history, err := s.getStatsHistory(7) // Last 7 days
if err != nil {
// Get historical data with empty defaults
history := &StatsHistory{
Pastes: make([]ChartDataPoint, 7),
URLs: make([]ChartDataPoint, 7),
Storage: make([]ChartDataPoint, 7),
}

if histData, err := s.getStatsHistory(7); err == nil {
history = histData
} else {
s.logger.Error("failed to get stats history", zap.Error(err))
// Continue without history
}

// Convert data to JSON strings
// Convert data to JSON strings with empty array fallbacks
pastesHistory, _ := json.Marshal(history.Pastes)
urlsHistory, _ := json.Marshal(history.URLs)
storageHistory, _ := json.Marshal(history.Storage)

// Get storage by file type data
storageByType, err := s.getStorageByFileType()
if err != nil {
// Get storage by file type data with empty map fallback
storageByType := make(map[string]int64)
if typeData, err := s.getStorageByFileType(); err == nil {
storageByType = typeData
} else {
s.logger.Error("failed to get storage by file type", zap.Error(err))
// Continue with empty data
storageByType = make(map[string]int64)
}

// Convert storageByType to JSON
storageByTypeJSON, _ := json.Marshal(storageByType)

// Get average paste size with zero default
var avgSize float64
if err := s.db.Model(&models.Paste{}).
Select("COALESCE(AVG(NULLIF(size, 0)), 0)").
Row().
Scan(&avgSize); err != nil {
s.logger.Error("failed to get average size", zap.Error(err))
avgSize = 0
}

// Get active API keys count
var activeApiKeys int64
s.db.Model(&models.APIKey{}).Where("verified = ?", true).Count(&activeApiKeys)

// Get popular extensions with empty map fallback
extensionStats := make(map[string]int64)
rows, err := s.db.Model(&models.Paste{}).
Select("extension, COUNT(*) as count").
Where("extension != ''").
Group("extension").
Order("count DESC").
Limit(10).
Rows()

if err == nil {
defer rows.Close()
for rows.Next() {
var ext string
var count int64
if err := rows.Scan(&ext, &count); err == nil {
extensionStats[ext] = count
}
}
} else {
s.logger.Error("failed to get extension stats", zap.Error(err))
}

// Get expiring content counts
var expiringPastes, expiringUrls int64
twentyFourHours := time.Now().Add(24 * time.Hour)
s.db.Model(&models.Paste{}).
Where("expires_at < ? AND expires_at > ?", twentyFourHours, time.Now()).
Count(&expiringPastes)
s.db.Model(&models.Shortlink{}).
Where("expires_at < ? AND expires_at > ?", twentyFourHours, time.Now()).
Count(&expiringUrls)

// Get private vs public paste ratio with zero defaults
var privatePastes int64
s.db.Model(&models.Paste{}).Where("private = ?", true).Count(&privatePastes)
publicPastes := totalPastes - privatePastes

// Calculate private ratio safely
var privateRatio float64
if totalPastes > 0 {
privateRatio = float64(privatePastes) / float64(totalPastes) * 100
}

// Get average paste views safely
var avgViews float64
if err := s.db.Model(&models.Paste{}).
Select("COALESCE(AVG(NULLIF(views, 0)), 0)").
Row().
Scan(&avgViews); err != nil {
s.logger.Error("failed to get average views", zap.Error(err))
avgViews = 0
}

// Get total storage used with zero default
totalStorage := s.getStorageSize()

return c.Render("stats", fiber.Map{
"stats": fiber.Map{
"pastes": totalPastes,
"urls": totalUrls,
"storage": humanize.IBytes(s.getStorageSize()),
// Current totals (these are safe as Count returns 0 if no rows)
"pastes": totalPastes,
"urls": totalUrls,
"storage": humanize.IBytes(totalStorage),
"activeApiKeys": activeApiKeys,

// Historical data (already has empty defaults)
"pastesHistory": string(pastesHistory),
"urlsHistory": string(urlsHistory),
"storageHistory": string(storageHistory),

// File type statistics (already has empty defaults)
"storageByType": string(storageByTypeJSON),
"extensionStats": extensionStats,
"avgSize": humanize.IBytes(uint64(avgSize)),

// Expiring content (safe as Count returns 0 if no rows)
"expiringPastes24h": expiringPastes,
"expiringUrls24h": expiringUrls,

// Additional metrics (with safe defaults)
"privatePastes": privatePastes,
"publicPastes": publicPastes,
"privateRatio": privateRatio,
"avgViews": avgViews,
},
"baseUrl": s.config.Server.BaseURL,
}, "layouts/main")
Expand Down Expand Up @@ -586,7 +681,7 @@ func (s *Server) handleRequestAPIKey(c *fiber.Ctx) error {
tempKey.Name = req.Name
tempKey.VerifyToken = utils.GenerateID(64)
tempKey.VerifyExpiry = time.Now().Add(24 * time.Hour)
tempKey.IsReset = true // Add this field to APIKey model
tempKey.IsReset = true

if err := s.db.Create(tempKey).Error; err != nil {
s.logger.Error("failed to create temporary verification",
Expand Down Expand Up @@ -1020,6 +1115,151 @@ func (s *Server) handleDeleteWithKey(c *fiber.Ctx) error {
})
}

// handleListURLs returns a paginated list of URLs for the API key
// Optional query params:
// - page: page number (default: 1)
// - limit: items per page (default: 20)
// - sort: sort order (default: "created_at desc")
func (s *Server) handleListURLs(c *fiber.Ctx) error {
if err := s.rateLimiter.Check(c.IP()); err != nil {
return err
}

apiKey := c.Locals("apiKey").(*models.APIKey)

// Get pagination params
page := c.QueryInt("page", 1)
limit := c.QueryInt("limit", 20)
sort := c.Query("sort", "created_at desc")

var urls []models.Shortlink
var total int64

// Build query
query := s.db.Model(&models.Shortlink{}).Where("api_key = ?", apiKey.Key)

// Get total count
query.Count(&total)

// Get paginated results
err := query.Order(sort).
Offset((page - 1) * limit).
Limit(limit).
Find(&urls).Error

if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch URLs")
}

// Convert to response format
items := []fiber.Map{}
for _, url := range urls {
response := url.ToResponse()
s.addBaseURLToPasteResponse(response)
items = append(items, response)
}

return c.JSON(fiber.Map{
"success": true,
"data": fiber.Map{
"items": items,
"total": total,
"page": page,
"limit": limit,
},
})
}

// handleUpdateURLExpiration updates a URL's expiration time
// Accepts: application/json with structure:
//
// {
// "expires_in": "string" // Required (e.g., "24h", or "never")
// }
func (s *Server) handleUpdateURLExpiration(c *fiber.Ctx) error {
if err := s.rateLimiter.Check(c.IP()); err != nil {
return err
}

id := c.Params("id")
apiKey := c.Locals("apiKey").(*models.APIKey)

var req struct {
ExpiresIn string `json:"expires_in"`
}

if err := c.BodyParser(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid JSON")
}

// Find URL
var shortlink models.Shortlink
if err := s.db.First(&shortlink, "id = ?", id).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, "URL not found")
}

// Check ownership
if shortlink.APIKey != apiKey.Key {
return fiber.NewError(fiber.StatusForbidden, "Not authorized to modify this URL")
}

// Update expiration
if req.ExpiresIn == "never" {
shortlink.ExpiresAt = nil
} else {
expiry, err := time.ParseDuration(req.ExpiresIn)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid expiration format")
}
expiryTime := time.Now().Add(expiry)
shortlink.ExpiresAt = &expiryTime
}

// Save changes
if err := s.db.Save(&shortlink).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update URL")
}

response := shortlink.ToResponse()
s.addBaseURLToPasteResponse(response)

return c.JSON(fiber.Map{
"success": true,
"data": response,
})
}

// handleDeleteURL deletes a URL (requires API key ownership)
func (s *Server) handleDeleteURL(c *fiber.Ctx) error {
if err := s.rateLimiter.Check(c.IP()); err != nil {
return err
}

id := c.Params("id")
apiKey := c.Locals("apiKey").(*models.APIKey)

// Find URL
var shortlink models.Shortlink
if err := s.db.First(&shortlink, "id = ?", id).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, "URL not found")
}

// Check ownership
if shortlink.APIKey != apiKey.Key {
return fiber.NewError(fiber.StatusForbidden, "Not authorized to delete this URL")
}

// Delete from database
if err := s.db.Delete(&shortlink).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete URL")
}

return c.JSON(fiber.Map{
"success": true,
"message": "URL deleted successfully",
})
}

// Helper Functions

// getStorageSize returns total size of stored files in bytes
Expand Down
Loading

0 comments on commit b9e1ef2

Please sign in to comment.