mirror of
https://github.com/lus/pasty.git
synced 2023-08-10 21:13:09 +03:00
Compare commits
13 Commits
b9a6a81821
...
3af880042d
Author | SHA1 | Date | |
---|---|---|---|
|
3af880042d | ||
|
b2ad618781 | ||
|
e2c9454430 | ||
|
9a794a82d0 | ||
|
e8a88e21ae | ||
|
5b0ba721b8 | ||
|
695b900f28 | ||
|
9708263373 | ||
|
05a27a00c0 | ||
|
18839a2021 | ||
|
c414fd7c59 | ||
|
127af0a907 | ||
|
bdac813e59 |
@ -3,8 +3,11 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/lus/pasty/internal/cleanup"
|
||||
"github.com/lus/pasty/internal/config"
|
||||
"github.com/lus/pasty/internal/consolecommands"
|
||||
"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 +31,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 +58,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 +85,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 +133,18 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Listen to console commands if enabled
|
||||
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 (&consolecommands.Router{
|
||||
Config: cfg,
|
||||
Storage: driver,
|
||||
}).Listen()
|
||||
}
|
||||
|
||||
// Wait for an interrupt signal
|
||||
log.Info().Msg("The application has been started. Use Ctrl+C to shut it down.")
|
||||
shutdownChan := make(chan os.Signal, 1)
|
||||
signal.Notify(shutdownChan, os.Interrupt)
|
||||
<-shutdownChan
|
||||
|
48
internal/cleanup/task.go
Normal file
48
internal/cleanup/task.go
Normal 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)
|
||||
}
|
72
internal/config/compatibility.go
Normal file
72
internal/config/compatibility.go
Normal 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
|
||||
}
|
@ -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 {
|
||||
|
27
internal/consolecommands/cmd_cleanup.go
Normal file
27
internal/consolecommands/cmd_cleanup.go
Normal file
@ -0,0 +1,27 @@
|
||||
package consolecommands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (router *Router) 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)
|
||||
}
|
28
internal/consolecommands/cmd_delete.go
Normal file
28
internal/consolecommands/cmd_delete.go
Normal file
@ -0,0 +1,28 @@
|
||||
package consolecommands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func (router *Router) 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)
|
||||
}
|
34
internal/consolecommands/cmd_set_modification_token.go
Normal file
34
internal/consolecommands/cmd_set_modification_token.go
Normal file
@ -0,0 +1,34 @@
|
||||
package consolecommands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func (router *Router) 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)
|
||||
}
|
73
internal/consolecommands/router.go
Normal file
73
internal/consolecommands/router.go
Normal file
@ -0,0 +1,73 @@
|
||||
package consolecommands
|
||||
|
||||
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 Router struct {
|
||||
Config *config.Config
|
||||
Storage storage.Driver
|
||||
}
|
||||
|
||||
func (router *Router) 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
|
||||
}
|
||||
}
|
||||
}
|
24
internal/maps/map_utils.go
Normal file
24
internal/maps/map_utils.go
Normal 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
|
||||
}
|
60
internal/reports/client.go
Normal file
60
internal/reports/client.go
Normal 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
|
||||
}
|
6
internal/static/static.go
Normal file
6
internal/static/static.go
Normal file
@ -0,0 +1,6 @@
|
||||
package static
|
||||
|
||||
var (
|
||||
MaxMetadataWidth = 10
|
||||
MaxMetadataDepth = 5
|
||||
)
|
@ -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")
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
48
internal/web/v2_end_report_paste.go
Normal file
48
internal/web/v2_end_report_paste.go
Normal 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)
|
||||
}
|
@ -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")
|
||||
|
22
pkg/chiimplicitok/middleware.go
Normal file
22
pkg/chiimplicitok/middleware.go
Normal 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
78
pkg/chizerolog/logger.go
Normal 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
|
||||
}
|
36
pkg/chizerolog/recoverer.go
Normal file
36
pkg/chizerolog/recoverer.go
Normal 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)
|
||||
}
|
Loading…
Reference in New Issue
Block a user