Compare commits

...

12 Commits

Author SHA1 Message Date
Lukas Schulte Pelkum b2ad618781
implement console commands 2023-06-17 21:52:09 +02:00
Lukas Schulte Pelkum e2c9454430
restrict paste metadata dimensions 2023-06-17 20:36:15 +02:00
Lukas Schulte Pelkum 9a794a82d0
use native middleware.AllowContentType 2023-06-17 20:07:19 +02:00
Lukas Schulte Pelkum e8a88e21ae
implement cleanup task 2023-06-17 18:55:55 +02:00
Lukas Schulte Pelkum 5b0ba721b8
validate request content type 2023-06-17 18:38:58 +02:00
Lukas Schulte Pelkum 695b900f28
write Content-Type and Content-Length headers 2023-06-17 18:35:26 +02:00
Lukas Schulte Pelkum 9708263373
print specific notices for legacy storage drivers 2023-06-17 18:27:11 +02:00
Lukas Schulte Pelkum 05a27a00c0
make config compatibility layer more abstract 2023-06-17 18:20:25 +02:00
Lukas Schulte Pelkum 18839a2021
implicitly write 200 HTTP status code 2023-06-17 17:10:37 +02:00
Lukas Schulte Pelkum c414fd7c59
implement request logging 2023-06-17 17:00:44 +02:00
Lukas Schulte Pelkum 127af0a907
print Hastebin support removal notice 2023-06-17 15:23:39 +02:00
Lukas Schulte Pelkum bdac813e59
implement paste reports 2023-06-17 15:16:22 +02:00
23 changed files with 693 additions and 67 deletions

View File

@ -0,0 +1,27 @@
package main
import (
"context"
"fmt"
"time"
)
func (router *consoleCommandRouter) Cleanup(args []string) {
if len(args) == 0 {
fmt.Println("Expected 1 argument.")
return
}
lifetime, err := time.ParseDuration(args[0])
if err != nil {
fmt.Printf("Could not parse duration: %s.\n", err.Error())
return
}
amount, err := router.Storage.Pastes().DeleteOlderThan(context.Background(), lifetime)
if err != nil {
if err != nil {
fmt.Printf("Could not delete pastes: %s.\n", err.Error())
return
}
}
fmt.Printf("Deleted %d pastes older than %s.\n", amount, lifetime)
}

View File

@ -0,0 +1,28 @@
package main
import (
"context"
"fmt"
)
func (router *consoleCommandRouter) Delete(args []string) {
if len(args) == 0 {
fmt.Println("Expected 1 argument.")
return
}
pasteID := args[0]
paste, err := router.Storage.Pastes().FindByID(context.Background(), pasteID)
if err != nil {
fmt.Printf("Could not look up paste: %s.\n", err.Error())
return
}
if paste == nil {
fmt.Printf("Invalid paste ID: %s.\n", pasteID)
return
}
if err := router.Storage.Pastes().DeleteByID(context.Background(), pasteID); err != nil {
fmt.Printf("Could not delete paste: %s.\n", err.Error())
return
}
fmt.Printf("Deleted paste %s.\n", pasteID)
}

View File

@ -0,0 +1,73 @@
package main
import (
"bufio"
"fmt"
"github.com/lus/pasty/internal/config"
"github.com/lus/pasty/internal/storage"
"github.com/rs/zerolog/log"
"os"
"regexp"
"strings"
"syscall"
)
var whitespaceRegex = regexp.MustCompile("\\s+")
type consoleCommandRouter struct {
Config *config.Config
Storage storage.Driver
}
func (router *consoleCommandRouter) Listen() {
reader := bufio.NewReader(os.Stdin)
for {
fmt.Print("> ")
input, err := reader.ReadString('\n')
if err != nil {
log.Err(err).Msg("Could not read console input.")
continue
}
commandData := strings.Split(whitespaceRegex.ReplaceAllString(strings.TrimSpace(input), " "), " ")
if len(commandData) == 0 {
fmt.Println("Invalid command.")
continue
}
handle := strings.ToLower(commandData[0])
var args []string
if len(commandData) > 1 {
args = commandData[1:]
}
switch handle {
case "help":
fmt.Println("Available commands:")
fmt.Println(" help : Shows this overview")
fmt.Println(" stop : Stops the application")
fmt.Println(" setmodtoken <id> <token> : Changes the modification token of the paste with ID <id> to <token>")
fmt.Println(" delete <id> : Deletes the paste with ID <id>")
fmt.Println(" cleanup <duration> : Deletes all pastes that are older than <duration>")
break
case "stop":
if err := syscall.Kill(syscall.Getpid(), syscall.SIGINT); err != nil {
fmt.Printf("Could not send interrupt signal: %s.\nUse Ctrl+C instead.\n", err.Error())
break
}
return
case "setmodtoken":
router.SetModificationToken(args)
break
case "delete":
router.Delete(args)
break
case "cleanup":
router.Cleanup(args)
break
default:
fmt.Println("Invalid command.")
break
}
}
}

View File

@ -0,0 +1,34 @@
package main
import (
"context"
"fmt"
)
func (router *consoleCommandRouter) SetModificationToken(args []string) {
if len(args) < 2 {
fmt.Println("Expected 2 arguments.")
return
}
pasteID := args[0]
newToken := args[1]
paste, err := router.Storage.Pastes().FindByID(context.Background(), pasteID)
if err != nil {
fmt.Printf("Could not look up paste: %s.\n", err.Error())
return
}
if paste == nil {
fmt.Printf("Invalid paste ID: %s.\n", pasteID)
return
}
paste.ModificationToken = newToken
if err := paste.HashModificationToken(); err != nil {
fmt.Printf("Could not hash modification token: %s.\n", err.Error())
return
}
if err := router.Storage.Pastes().Upsert(context.Background(), paste); err != nil {
fmt.Printf("Could not update paste: %s.\n", err.Error())
return
}
fmt.Printf("Changed modification token of paste %s to %s.\n", pasteID, newToken)
}

View File

@ -3,8 +3,10 @@ package main
import (
"context"
"errors"
"github.com/lus/pasty/internal/cleanup"
"github.com/lus/pasty/internal/config"
"github.com/lus/pasty/internal/meta"
"github.com/lus/pasty/internal/reports"
"github.com/lus/pasty/internal/storage"
"github.com/lus/pasty/internal/storage/postgres"
"github.com/lus/pasty/internal/storage/sqlite"
@ -28,6 +30,7 @@ func main() {
}
// Load the configuration
config.Compatibility()
cfg, err := config.Load()
if err != nil {
log.Fatal().Err(err).Msg("Could not load the configuration.")
@ -54,6 +57,15 @@ func main() {
case "sqlite":
driver = sqlite.New(cfg.SQLite.File)
break
case "file":
// TODO: Readme notice
log.Fatal().Msg("You have configured the legacy 'file' storage driver. This storage driver has been removed in favor of PostgreSQL and SQLite, but the latter one may be a seamless alternative for you. Head over to the projects README for more information.")
break
case "mongodb":
case "s3":
// TODO: Readme notice
log.Fatal().Msg("You have configured a legacy storage driver. This storage driver has been removed in favor of PostgreSQL and SQLite, but the migration process is well-documented. Head over to the projects README for more information.")
break
default:
log.Fatal().Str("driver_name", cfg.StorageDriver).Msg("An invalid storage driver name was given.")
return
@ -72,23 +84,41 @@ func main() {
}
}()
// Start the web server
log.Info().Str("address", cfg.WebAddress).Msg("Starting the web server...")
var adminTokens []string
if cfg.ModificationTokenMaster != "" {
adminTokens = []string{cfg.ModificationTokenMaster}
// Schedule the cleanup task if configured
if cfg.Cleanup.Enabled {
task := &cleanup.Task{
Interval: cfg.Cleanup.TaskInterval,
MaxPasteAge: cfg.Cleanup.PasteLifetime,
Repository: driver.Pastes(),
}
log.Info().Msg("Scheduling the cleanup task...")
task.Start()
defer func() {
log.Info().Msg("Shutting down the cleanup task...")
task.Stop()
}()
}
// Start the web server
log.Info().Str("address", cfg.Address).Msg("Starting the web server...")
webServer := &web.Server{
Address: cfg.WebAddress,
Address: cfg.Address,
Storage: driver,
HastebinSupport: cfg.HastebinSupport,
PasteIDLength: cfg.IDLength,
PasteIDCharset: cfg.IDCharacters,
PasteLengthCap: cfg.LengthCap,
ModificationTokensEnabled: cfg.ModificationTokens,
PasteIDLength: cfg.PasteIDLength,
PasteIDCharset: cfg.PasteIDCharset,
PasteLengthCap: cfg.PasteLengthCap,
ModificationTokensEnabled: cfg.ModificationTokensEnabled,
ModificationTokenLength: cfg.ModificationTokenLength,
ModificationTokenCharset: cfg.ModificationTokenCharacters,
AdminTokens: adminTokens,
ModificationTokenCharset: cfg.ModificationTokenCharset,
}
if cfg.Reports.Enabled {
webServer.ReportClient = &reports.Client{
WebhookURL: cfg.Reports.WebhookURL,
WebhookToken: cfg.Reports.WebhookToken,
}
}
if cfg.ModificationTokenMaster != "" {
webServer.AdminTokens = []string{cfg.ModificationTokenMaster}
}
go func() {
if err := webServer.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) {
@ -102,8 +132,16 @@ func main() {
}
}()
// Wait for an interrupt signal
log.Info().Msg("The application has been started. Use Ctrl+C to shut it down.")
if !cfg.ConsoleCommandsEnabled {
log.Info().Msg("The application has been started. Use Ctrl+C to shut it down.")
} else {
log.Info().Msg("The application has been started and listens to console commands. Use Ctrl+C or 'stop' to shut it down.")
go (&consoleCommandRouter{
Config: cfg,
Storage: driver,
}).Listen()
}
shutdownChan := make(chan os.Signal, 1)
signal.Notify(shutdownChan, os.Interrupt)
<-shutdownChan

48
internal/cleanup/task.go Normal file
View File

@ -0,0 +1,48 @@
package cleanup
import (
"context"
"github.com/lus/pasty/internal/pastes"
"github.com/rs/zerolog/log"
"time"
)
type Task struct {
Interval time.Duration
MaxPasteAge time.Duration
Repository pastes.Repository
running bool
stop chan struct{}
}
func (task *Task) Start() {
if task.running {
return
}
task.stop = make(chan struct{}, 1)
go func() {
for {
select {
case <-time.After(task.Interval):
n, err := task.Repository.DeleteOlderThan(context.Background(), task.MaxPasteAge)
if err != nil {
log.Err(err).Msg("Could not clean up expired pastes.")
continue
}
log.Debug().Int("amount", n).Msg("Cleaned up expired pastes.")
case <-task.stop:
task.running = false
return
}
}
}()
task.running = true
}
func (task *Task) Stop() {
if !task.running {
return
}
close(task.stop)
}

View File

@ -0,0 +1,72 @@
package config
import (
"github.com/joho/godotenv"
"github.com/rs/zerolog/log"
"os"
)
var removedKeys = []string{
"PASTY_HASTEBIN_SUPPORT",
"PASTY_STORAGE_FILE_PATH",
"PASTY_STORAGE_MONGODB_CONNECTION_STRING",
"PASTY_STORAGE_MONGODB_DATABASE",
"PASTY_STORAGE_MONGODB_COLLECTION",
"PASTY_STORAGE_S3_ENDPOINT",
"PASTY_STORAGE_S3_ACCESS_KEY_ID",
"PASTY_STORAGE_S3_SECRET_ACCESS_KEY",
"PASTY_STORAGE_S3_SECRET_TOKEN",
"PASTY_STORAGE_S3_SECURE",
"PASTY_STORAGE_S3_REGION",
"PASTY_STORAGE_S3_BUCKET",
}
var keyRedirects = map[string][]string{
"PASTY_ADDRESS": {"PASTY_WEB_ADDRESS"},
"PASTY_STORAGE_DRIVER": {"PASTY_STORAGE_TYPE"},
"PASTY_POSTGRES_DSN": {"PASTY_STORAGE_POSTGRES_DSN"},
"PASTY_PASTE_ID_LENGTH": {"PASTY_ID_LENGTH"},
"PASTY_PASTE_ID_CHARSET": {"PASTY_ID_CHARACTERS"},
"PASTY_PASTE_LENGTH_CAP": {"PASTY_LENGTH_CAP"},
"PASTY_REPORTS_ENABLED": {"PASTY_REPORTS_ENABLED"},
"PASTY_REPORTS_WEBHOOK_URL": {"PASTY_REPORT_WEBHOOK"},
"PASTY_REPORTS_WEBHOOK_TOKEN": {"PASTY_REPORT_WEBHOOK_TOKEN"},
"PASTY_CLEANUP_ENABLED": {"PASTY_AUTODELETE"},
"PASTY_CLEANUP_PASTE_LIFETIME": {"PASTY_AUTODELETE_LIFETIME"},
"PASTY_CLEANUP_TASK_INTERVAL": {"PASTY_AUTODELETE_TASK_INTERVAL"},
"PASTY_MODIFICATION_TOKENS_ENABLED": {"PASTY_MODIFICATION_TOKENS", "PASTY_DELETION_TOKENS"},
"PASTY_MODIFICATION_TOKEN_CHARSET": {"PASTY_MODIFICATION_TOKEN_CHARACTERS"},
"PASTY_MODIFICATION_TOKEN_MASTER": {"PASTY_DELETION_TOKEN_MASTER"},
"PASTY_MODIFICATION_TOKEN_LENGTH": {"PASTY_DELETION_TOKEN_LENGTH"},
}
// Compatibility runs several compatibility measurements.
// This is used to redirect legacy config keys to their new equivalent or print warnings about deprecated ones.
func Compatibility() {
_ = godotenv.Overload()
for _, key := range removedKeys {
if isSet(key) {
log.Warn().Msgf("You have set the '%s' environment variable. This variable has been discontinued and has no further effect.", key)
}
}
for newKey, oldKeys := range keyRedirects {
if !isSet(newKey) {
for _, oldKey := range oldKeys {
if isSet(oldKey) {
if err := os.Setenv(newKey, os.Getenv(oldKey)); err != nil {
continue
}
log.Warn().Msgf("You have set the '%s' environment variable. This variable has been renamed to '%s'. The value has been propagated, but please consider adjusting your configuration to avoid further complications.", oldKey, newKey)
break
}
}
}
}
}
func isSet(key string) bool {
_, ok := os.LookupEnv(key)
return ok
}

View File

@ -7,28 +7,28 @@ import (
)
type Config struct {
LogLevel string `default:"info" split_words:"true"`
WebAddress string `default:":8080" split_words:"true"`
StorageDriver string `default:"sqlite" split_words:"true"`
HastebinSupport bool `default:"false" split_words:"true"`
IDLength int `default:"6" split_words:"true"`
IDCharacters string `default:"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" split_words:"true"`
ModificationTokens bool `default:"true" split_words:"true"`
ModificationTokenMaster string `split_words:"true"`
ModificationTokenLength int `default:"12" split_words:"true"`
ModificationTokenCharacters string `default:"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" split_words:"true"`
RateLimit string `default:"30-M" split_words:"true"`
LengthCap int `default:"50000" split_words:"true"`
AutoDelete *AutoDeleteConfig `split_words:"true"`
Reports *ReportConfig
Postgres *PostgresConfig
SQLite *SQLiteConfig
LogLevel string `default:"info" split_words:"true"`
Address string `default:":8080" split_words:"true"`
StorageDriver string `default:"sqlite" split_words:"true"`
PasteIDLength int `default:"6" split_words:"true"`
PasteIDCharset string `default:"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" split_words:"true"`
ModificationTokensEnabled bool `default:"true" split_words:"true"`
ModificationTokenMaster string `split_words:"true"`
ModificationTokenLength int `default:"12" split_words:"true"`
ModificationTokenCharset string `default:"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" split_words:"true"`
RateLimit string `default:"30-M" split_words:"true"`
PasteLengthCap int `default:"50000" split_words:"true"`
ConsoleCommandsEnabled bool `default:"true" split_words:"true"`
Cleanup *CleanupConfig
Reports *ReportConfig
Postgres *PostgresConfig
SQLite *SQLiteConfig
}
type AutoDeleteConfig struct {
Enabled bool `default:"false"`
Lifetime time.Duration `default:"720h"`
TaskInterval time.Duration `default:"5m" split_words:"true"`
type CleanupConfig struct {
Enabled bool `default:"false"`
PasteLifetime time.Duration `default:"720h" split_words:"true"`
TaskInterval time.Duration `default:"5m" split_words:"true"`
}
type ReportConfig struct {

View File

@ -0,0 +1,24 @@
package maps
func ExceedsDimensions(src map[string]any, width, depth int) bool {
if width < 0 || depth < 1 || len(src) > width {
return true
}
for _, value := range src {
childMap, ok := value.(map[string]any)
if !ok {
continue
}
if depth == 1 {
return true
}
if ExceedsDimensions(childMap, width, depth-1) {
return true
}
}
return false
}

View File

@ -0,0 +1,60 @@
package reports
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
type Report struct {
Paste string `json:"paste"`
Reason string `json:"reason"`
}
type Response struct {
Success bool `json:"success"`
Message string `json:"message"`
}
type Client struct {
WebhookURL string
WebhookToken string
}
func (client *Client) Send(report *Report) (*Response, error) {
data, err := json.Marshal(report)
if err != nil {
return nil, err
}
request, err := http.NewRequest(http.MethodPost, client.WebhookURL, bytes.NewReader(data))
if err != nil {
return nil, err
}
if client.WebhookToken != "" {
request.Header.Set("Authorization", "Bearer "+client.WebhookToken)
}
response, err := http.DefaultClient.Do(request)
if err != nil {
return nil, err
}
body, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
if response.StatusCode < 200 || response.StatusCode > 299 {
return nil, fmt.Errorf("the report webhook responded with an unexpected error: %d (%s)", response.StatusCode, string(body))
}
reportResponse := new(Response)
if err := json.Unmarshal(body, &reportResponse); err != nil {
return nil, err
}
return reportResponse, nil
}

View File

@ -0,0 +1,6 @@
package static
var (
MaxMetadataWidth = 10
MaxMetadataDepth = 5
)

View File

@ -31,7 +31,7 @@ func frontendHandler(notFoundHandler http.HandlerFunc) http.HandlerFunc {
}
return
}
writeErr(writer, err)
writeErr(request, writer, err)
return
}
defer func() {
@ -40,7 +40,7 @@ func frontendHandler(notFoundHandler http.HandlerFunc) http.HandlerFunc {
fileInfo, err := file.Stat()
if err != nil {
writeErr(writer, err)
writeErr(request, writer, err)
return
}
@ -55,7 +55,7 @@ func frontendHandler(notFoundHandler http.HandlerFunc) http.HandlerFunc {
content, err := io.ReadAll(file)
if err != nil {
writeErr(writer, err)
writeErr(request, writer, err)
return
}
@ -65,10 +65,10 @@ func frontendHandler(notFoundHandler http.HandlerFunc) http.HandlerFunc {
}
}
func serveIndexFile(writer http.ResponseWriter, _ *http.Request) {
func serveIndexFile(writer http.ResponseWriter, request *http.Request) {
indexFile, err := frontend.ReadFile("frontend/index.html")
if err != nil {
writeErr(writer, err)
writeErr(request, writer, err)
return
}
writer.Header().Set("Content-Type", "text/html")

View File

@ -2,14 +2,21 @@ package web
import (
"encoding/json"
"github.com/lus/pasty/pkg/chizerolog"
"net/http"
"strconv"
)
func writeErr(writer http.ResponseWriter, err error) {
func writeErr(request *http.Request, writer http.ResponseWriter, err error) {
chizerolog.InjectError(request, err)
writer.Header().Set("Content-Type", "text/plain")
writer.Header().Set("Content-Length", strconv.Itoa(len(err.Error())))
writeString(writer, http.StatusInternalServerError, err.Error())
}
func writeString(writer http.ResponseWriter, status int, value string) {
writer.Header().Set("Content-Type", "text/plain")
writer.Header().Set("Content-Length", strconv.Itoa(len(value)))
writer.WriteHeader(status)
writer.Write([]byte(value))
}
@ -20,14 +27,16 @@ func writeJSON(writer http.ResponseWriter, status int, value any) error {
return err
}
writer.Header().Set("Content-Type", "application/json")
writer.Header().Set("Content-Length", strconv.Itoa(len(jsonData)))
writer.WriteHeader(status)
writer.Write(jsonData)
return nil
}
func writeJSONOrErr(writer http.ResponseWriter, status int, value any) {
func writeJSONOrErr(request *http.Request, writer http.ResponseWriter, status int, value any) {
if err := writeJSON(writer, status, value); err != nil {
writeErr(writer, err)
writeErr(request, writer, err)
}
}

View File

@ -3,9 +3,13 @@ package web
import (
"context"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/lus/pasty/internal/meta"
"github.com/lus/pasty/internal/pastes"
"github.com/lus/pasty/internal/reports"
"github.com/lus/pasty/internal/storage"
"github.com/lus/pasty/pkg/chiimplicitok"
"github.com/lus/pasty/pkg/chizerolog"
"net/http"
)
@ -16,9 +20,9 @@ type Server struct {
// The storage driver to use.
Storage storage.Driver
// Whether the Hastebin support should be enabled.
// If this is set to 'false', the Hastebin specific endpoints will not be registered.
HastebinSupport bool
// The report client to use to send reports.
// If this is set to nil, the report system will be considered deactivated.
ReportClient *reports.Client
// The length of newly generated paste IDs.
PasteIDLength int
@ -44,6 +48,10 @@ type Server struct {
func (server *Server) Start() error {
router := chi.NewRouter()
router.Use(chizerolog.Logger)
router.Use(chizerolog.Recover)
router.Use(chiimplicitok.Middleware)
// Register the web frontend handler
router.Get("/*", frontendHandler(router.NotFoundHandler()))
@ -60,15 +68,18 @@ func (server *Server) Start() error {
// Register the paste API endpoints
router.Get("/api/*", router.NotFoundHandler())
router.With(server.v2MiddlewareInjectPaste).Get("/api/v2/pastes/{paste_id}", server.v2EndpointGetPaste)
router.Post("/api/v2/pastes", server.v2EndpointCreatePaste)
router.With(server.v2MiddlewareInjectPaste, server.v2MiddlewareAuthorize).Patch("/api/v2/pastes/{paste_id}", server.v2EndpointModifyPaste)
router.With(middleware.AllowContentType("application/json")).Post("/api/v2/pastes", server.v2EndpointCreatePaste)
router.With(middleware.AllowContentType("application/json"), server.v2MiddlewareInjectPaste, server.v2MiddlewareAuthorize).Patch("/api/v2/pastes/{paste_id}", server.v2EndpointModifyPaste)
router.With(server.v2MiddlewareInjectPaste, server.v2MiddlewareAuthorize).Delete("/api/v2/pastes/{paste_id}", server.v2EndpointDeletePaste)
if server.ReportClient != nil {
router.With(middleware.AllowContentType("application/json"), server.v2MiddlewareInjectPaste).Post("/api/v2/pastes/{paste_id}/report", server.v2EndpointReportPaste)
}
router.Get("/api/v2/info", func(writer http.ResponseWriter, request *http.Request) {
writeJSONOrErr(writer, http.StatusOK, map[string]any{
writeJSONOrErr(request, writer, http.StatusOK, map[string]any{
"version": meta.Version,
"modificationTokens": server.ModificationTokensEnabled,
"reports": false, // TODO: Return report state
"pasteLifetime": -1, // TODO: Return paste lifetime
"reports": server.ReportClient != nil,
"pasteLifetime": -1, // TODO: Return paste lifetime
})
})

View File

@ -2,8 +2,11 @@ package web
import (
"encoding/json"
"fmt"
"github.com/lus/pasty/internal/maps"
"github.com/lus/pasty/internal/pastes"
"github.com/lus/pasty/internal/randx"
"github.com/lus/pasty/internal/static"
"io"
"net/http"
"time"
@ -18,12 +21,12 @@ func (server *Server) v2EndpointCreatePaste(writer http.ResponseWriter, request
// Read, parse and validate the request payload
body, err := io.ReadAll(request.Body)
if err != nil {
writeErr(writer, err)
writeErr(request, writer, err)
return
}
payload := new(v2EndpointCreatePastePayload)
if err := json.Unmarshal(body, payload); err != nil {
writeErr(writer, err)
writeErr(request, writer, err)
return
}
if payload.Content == "" {
@ -34,10 +37,14 @@ func (server *Server) v2EndpointCreatePaste(writer http.ResponseWriter, request
writeString(writer, http.StatusBadRequest, "too large paste content")
return
}
if payload.Metadata != nil && maps.ExceedsDimensions(payload.Metadata, static.MaxMetadataWidth, static.MaxMetadataDepth) {
writeString(writer, http.StatusBadRequest, fmt.Sprintf("metadata exceeds maximum dimensions (max. width: %d; max. depth: %d)", static.MaxMetadataWidth, static.MaxMetadataDepth))
return
}
id, err := pastes.GenerateID(request.Context(), server.Storage.Pastes(), server.PasteIDCharset, server.PasteIDLength)
if err != nil {
writeErr(writer, err)
writeErr(request, writer, err)
return
}
@ -54,17 +61,17 @@ func (server *Server) v2EndpointCreatePaste(writer http.ResponseWriter, request
paste.ModificationToken = modificationToken
if err := paste.HashModificationToken(); err != nil {
writeErr(writer, err)
writeErr(request, writer, err)
return
}
}
if err := server.Storage.Pastes().Upsert(request.Context(), paste); err != nil {
writeErr(writer, err)
writeErr(request, writer, err)
return
}
cpy := *paste
cpy.ModificationToken = modificationToken
writeJSONOrErr(writer, http.StatusCreated, cpy)
writeJSONOrErr(request, writer, http.StatusCreated, cpy)
}

View File

@ -13,6 +13,6 @@ func (server *Server) v2EndpointDeletePaste(writer http.ResponseWriter, request
}
if err := server.Storage.Pastes().DeleteByID(request.Context(), paste.ID); err != nil {
writeErr(writer, err)
writeErr(request, writer, err)
}
}

View File

@ -1,6 +1,7 @@
package web
import (
"errors"
"github.com/lus/pasty/internal/pastes"
"net/http"
)
@ -8,11 +9,11 @@ import (
func (server *Server) v2EndpointGetPaste(writer http.ResponseWriter, request *http.Request) {
paste, ok := request.Context().Value("paste").(*pastes.Paste)
if !ok {
writeString(writer, http.StatusInternalServerError, "missing paste object")
writeErr(request, writer, errors.New("missing paste object"))
return
}
cpy := *paste
cpy.ModificationToken = ""
writeJSONOrErr(writer, http.StatusOK, cpy)
writeJSONOrErr(request, writer, http.StatusOK, cpy)
}

View File

@ -2,7 +2,10 @@ package web
import (
"encoding/json"
"fmt"
"github.com/lus/pasty/internal/maps"
"github.com/lus/pasty/internal/pastes"
"github.com/lus/pasty/internal/static"
"io"
"net/http"
)
@ -22,12 +25,12 @@ func (server *Server) v2EndpointModifyPaste(writer http.ResponseWriter, request
// Read, parse and validate the request payload
body, err := io.ReadAll(request.Body)
if err != nil {
writeErr(writer, err)
writeErr(request, writer, err)
return
}
payload := new(v2EndpointModifyPastePayload)
if err := json.Unmarshal(body, payload); err != nil {
writeErr(writer, err)
writeErr(request, writer, err)
return
}
if payload.Content != nil && *payload.Content == "" {
@ -38,6 +41,10 @@ func (server *Server) v2EndpointModifyPaste(writer http.ResponseWriter, request
writeString(writer, http.StatusBadRequest, "too large paste content")
return
}
if payload.Metadata != nil && maps.ExceedsDimensions(payload.Metadata, static.MaxMetadataWidth, static.MaxMetadataDepth) {
writeString(writer, http.StatusBadRequest, fmt.Sprintf("metadata exceeds maximum dimensions (max. width: %d; max. depth: %d)", static.MaxMetadataWidth, static.MaxMetadataDepth))
return
}
// Modify the paste itself
if payload.Content != nil {
@ -55,6 +62,6 @@ func (server *Server) v2EndpointModifyPaste(writer http.ResponseWriter, request
// Save the modified paste
if err := server.Storage.Pastes().Upsert(request.Context(), paste); err != nil {
writeErr(writer, err)
writeErr(request, writer, err)
}
}

View File

@ -0,0 +1,48 @@
package web
import (
"encoding/json"
"github.com/lus/pasty/internal/pastes"
"github.com/lus/pasty/internal/reports"
"io"
"net/http"
)
type v2EndpointReportPastePayload struct {
Reason string `json:"reason"`
}
func (server *Server) v2EndpointReportPaste(writer http.ResponseWriter, request *http.Request) {
paste, ok := request.Context().Value("paste").(*pastes.Paste)
if !ok {
writeString(writer, http.StatusInternalServerError, "missing paste object")
return
}
// Read, parse and validate the request payload
body, err := io.ReadAll(request.Body)
if err != nil {
writeErr(request, writer, err)
return
}
payload := new(v2EndpointReportPastePayload)
if err := json.Unmarshal(body, payload); err != nil {
writeErr(request, writer, err)
return
}
if payload.Reason == "" {
writeString(writer, http.StatusBadRequest, "missing report reason")
return
}
report := &reports.Report{
Paste: paste.ID,
Reason: payload.Reason,
}
response, err := server.ReportClient.Send(report)
if err != nil {
writeErr(request, writer, err)
return
}
writeJSONOrErr(request, writer, http.StatusOK, response)
}

View File

@ -17,10 +17,7 @@ func (server *Server) v2MiddlewareInjectPaste(next http.Handler) http.Handler {
paste, err := server.Storage.Pastes().FindByID(request.Context(), pasteID)
if err != nil {
if pasteID == "" {
writeErr(writer, err)
return
}
writeErr(request, writer, err)
}
if paste == nil {
writeString(writer, http.StatusNotFound, "paste not found")

View File

@ -0,0 +1,22 @@
package chiimplicitok
import (
"github.com/go-chi/chi/v5/middleware"
"net/http"
)
// Middleware sets the status code of a request to http.StatusOK if it was not set explicitly by any handler.
func Middleware(next http.Handler) http.Handler {
fn := func(writer http.ResponseWriter, request *http.Request) {
proxy := middleware.NewWrapResponseWriter(writer, request.ProtoMajor)
defer func() {
if proxy.Status() == 0 {
proxy.WriteHeader(http.StatusOK)
}
}()
next.ServeHTTP(proxy, request)
}
return http.HandlerFunc(fn)
}

78
pkg/chizerolog/logger.go Normal file
View File

@ -0,0 +1,78 @@
package chizerolog
import (
"context"
"fmt"
"github.com/go-chi/chi/v5/middleware"
"github.com/rs/zerolog/log"
"net/http"
"time"
)
const dataKey = "chzl_meta"
// Logger uses the global zerolog logger to log HTTP requests.
// Log messages are printed with the debug level.
// This middleware should be registered first.
func Logger(next http.Handler) http.Handler {
fn := func(writer http.ResponseWriter, request *http.Request) {
request = request.WithContext(context.WithValue(request.Context(), dataKey, make(map[string]any)))
proxy := middleware.NewWrapResponseWriter(writer, request.ProtoMajor)
start := time.Now()
defer func() {
end := time.Now()
scheme := "http"
if request.TLS != nil {
scheme = "https"
}
url := fmt.Sprintf("%s://%s%s", scheme, request.Host, request.RequestURI)
var err error
data := request.Context().Value(dataKey)
if data != nil {
injErr, ok := data.(map[string]any)["err"]
if ok {
err = injErr.(error)
}
}
if err == nil {
log.Debug().
Str("proto", request.Proto).
Str("method", request.Method).
Str("route", url).
Str("client_address", request.RemoteAddr).
Int("response_size", proxy.BytesWritten()).
Str("elapsed", fmt.Sprintf("%s", end.Sub(start))).
Int("status_code", proxy.Status()).
Msg("An incoming request has been processed.")
} else {
log.Error().
Err(err).
Str("proto", request.Proto).
Str("method", request.Method).
Str("route", url).
Str("client_address", request.RemoteAddr).
Int("response_size", proxy.BytesWritten()).
Str("elapsed", fmt.Sprintf("%s", end.Sub(start))).
Int("status_code", proxy.Status()).
Msg("An incoming request has been processed and resulted in an unexpected error.")
}
}()
next.ServeHTTP(proxy, request)
}
return http.HandlerFunc(fn)
}
// InjectError injects the given error to a specific key so that Logger will log its occurrence later on in the request chain.
func InjectError(request *http.Request, err error) {
data := request.Context().Value(dataKey)
if data == nil {
return
}
data.(map[string]any)["err"] = err
}

View File

@ -0,0 +1,36 @@
package chizerolog
import (
"fmt"
"github.com/rs/zerolog/log"
"net/http"
"runtime/debug"
)
// Recover recovers any call to panic() made by a request handler or middleware.
// It also logs an error-levelled message using the global zerolog logger.
// This middleware should be registered first (or second if Logger is also used).
func Recover(next http.Handler) http.Handler {
fn := func(writer http.ResponseWriter, request *http.Request) {
defer func() {
scheme := "http"
if request.TLS != nil {
scheme = "https"
}
url := fmt.Sprintf("%s://%s%s", scheme, request.Host, request.RequestURI)
if rec := recover(); rec != nil {
log.Error().
Str("proto", request.Proto).
Str("method", request.Method).
Str("route", url).
Interface("recovered", rec).
Bytes("stack", debug.Stack()).
Msg("A request handler has panicked.")
http.Error(writer, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}()
next.ServeHTTP(writer, request)
}
return http.HandlerFunc(fn)
}