mirror of
https://github.com/lus/pasty.git
synced 2023-08-10 21:13:09 +03:00
Merge 941da057ae
into a163acf036
This commit is contained in:
commit
fcb3384143
18
README.md
18
README.md
@ -1,22 +1,6 @@
|
||||
# pasty
|
||||
Pasty is a fast and lightweight code pasting server
|
||||
|
||||
## !!! Important deprecation notices !!!
|
||||
|
||||
> This version of pasty uses a new field name for the so far called `deletionToken`: `modificationToken`.
|
||||
> Instances using **PostgreSQL** are **not affected** as a corresponding SQL migration will run before the first startup.
|
||||
> If you however use **another storage driver** you may have to **update the entries** by hand or using a simple query, depending on your driver as I don't plan to ship migrations for every single storage driver.
|
||||
> It may be important to know that the **data migrator has been upgraded** too. This may serve as a **convenient workaround** (export data (field will be renamed) and import data with changed field names again).
|
||||
>
|
||||
> The old `deletionToken` field will be processed corresponding to these changes but I strongly recommend updating old pastes if possible.
|
||||
|
||||
> Additionally, I changed the three `DELETION_TOKEN*`environment variables to their corresponding `MODIFICATION_TOKEN*` ones:
|
||||
> - `DELETION_TOKENS` -> `MODIFICATION_TOKENS`
|
||||
> - `DELETION_TOKEN_MASTER` -> `MODIFICATION_TOKEN_MASTER`
|
||||
> - `DELETION_TOKEN_LENGTH` -> `MODIFICATION_TOKEN_LENGTH`
|
||||
>
|
||||
> Again, **the old ones will still work** because I do not want to jumble your configurations. However, **please consider updating** them to stay future-proof ^^.
|
||||
|
||||
pasty is a fast and lightweight code pasting server.
|
||||
|
||||
## Support
|
||||
|
||||
|
@ -1,52 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"context"
|
||||
"github.com/lus/pasty/internal/config"
|
||||
"github.com/lus/pasty/internal/meta"
|
||||
"github.com/lus/pasty/internal/storage"
|
||||
"github.com/lus/pasty/internal/web"
|
||||
"github.com/lus/pasty/internal/storage/postgres"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load the configuration
|
||||
log.Println("Loading the application configuration...")
|
||||
config.Load()
|
||||
// Set up the logger
|
||||
zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMs
|
||||
if !meta.IsProdEnvironment() {
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{
|
||||
Out: os.Stderr,
|
||||
})
|
||||
log.Warn().Msg("This distribution was compiled for development mode and is thus not meant to be run in production!")
|
||||
}
|
||||
|
||||
// Load the configured storage driver
|
||||
log.Println("Loading the configured storage driver...")
|
||||
err := storage.Load()
|
||||
// Load the configuration
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
log.Fatal().Err(err).Msg("Could not load the configuration.")
|
||||
}
|
||||
|
||||
// Adjust the log level
|
||||
if !meta.IsProdEnvironment() {
|
||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
} else {
|
||||
level, err := zerolog.ParseLevel(cfg.LogLevel)
|
||||
if err != nil {
|
||||
log.Warn().Msg("An invalid log level was configured. Falling back to 'info'.")
|
||||
level = zerolog.InfoLevel
|
||||
}
|
||||
zerolog.SetGlobalLevel(level)
|
||||
}
|
||||
|
||||
// Determine the correct storage driver to use
|
||||
var driver storage.Driver
|
||||
switch strings.TrimSpace(strings.ToLower(cfg.StorageDriver)) {
|
||||
case "postgres":
|
||||
driver = postgres.New(cfg.Postgres.DSN)
|
||||
break
|
||||
default:
|
||||
log.Fatal().Str("driver_name", cfg.StorageDriver).Msg("An invalid storage driver name was given.")
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize the configured storage driver
|
||||
log.Info().Str("driver_name", cfg.StorageDriver).Msg("Initializing the storage driver...")
|
||||
if err := driver.Initialize(context.Background()); err != nil {
|
||||
log.Fatal().Err(err).Str("driver_name", cfg.StorageDriver).Msg("The storage driver could not be initialized.")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
log.Println("Terminating the storage driver...")
|
||||
err := storage.Current.Terminate()
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
log.Info().Msg("Shutting down the storage driver...")
|
||||
if err := driver.Close(); err != nil {
|
||||
log.Err(err).Str("driver_name", cfg.StorageDriver).Msg("Could not shut down the storage driver.")
|
||||
}
|
||||
}()
|
||||
|
||||
// Schedule the AutoDelete task
|
||||
if config.Current.AutoDelete.Enabled {
|
||||
log.Println("Scheduling the AutoDelete task...")
|
||||
go func() {
|
||||
for {
|
||||
// Run the cleanup sequence
|
||||
deleted, err := storage.Current.Cleanup()
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
log.Printf("AutoDelete: Deleted %d expired pastes", deleted)
|
||||
|
||||
// Wait until the process should repeat
|
||||
time.Sleep(config.Current.AutoDelete.TaskInterval)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Serve the web resources
|
||||
log.Println("Serving the web resources...")
|
||||
panic(web.Serve())
|
||||
// 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
|
||||
}
|
||||
|
@ -1,82 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/lus/pasty/internal/config"
|
||||
"github.com/lus/pasty/internal/shared"
|
||||
"github.com/lus/pasty/internal/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Validate the command line arguments
|
||||
if len(os.Args) != 3 {
|
||||
panic("Invalid command line arguments")
|
||||
}
|
||||
|
||||
// Load the configuration
|
||||
log.Println("Loading the application configuration...")
|
||||
config.Load()
|
||||
|
||||
// Create and initialize the first (from) driver
|
||||
from, err := storage.GetDriver(shared.StorageType(os.Args[1]))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = from.Initialize()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create and initialize the second (to) driver
|
||||
to, err := storage.GetDriver(shared.StorageType(os.Args[2]))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = to.Initialize()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Retrieve a list of IDs from the first (from) driver
|
||||
ids, err := from.ListIDs()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Transfer every paste to the second (to) driver
|
||||
for _, id := range ids {
|
||||
log.Println("Transferring ID " + id + "...")
|
||||
|
||||
// Retrieve the paste
|
||||
paste, err := from.Get(id)
|
||||
if err != nil {
|
||||
log.Println("[ERR]", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
// Move the content of the deletion token field to the modification field
|
||||
if paste.DeletionToken != "" {
|
||||
if paste.ModificationToken == "" {
|
||||
paste.ModificationToken = paste.DeletionToken
|
||||
}
|
||||
paste.DeletionToken = ""
|
||||
log.Println("[INFO] Paste " + id + " was a legacy one.")
|
||||
}
|
||||
|
||||
// Initialize a new metadata map if the old one is null
|
||||
if paste.Metadata == nil {
|
||||
paste.Metadata = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// Save the paste
|
||||
err = to.Save(paste)
|
||||
if err != nil {
|
||||
log.Println("[ERR]", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
log.Println("Transferred ID " + id + ".")
|
||||
}
|
||||
}
|
42
go.mod
42
go.mod
@ -1,23 +1,29 @@
|
||||
module github.com/lus/pasty
|
||||
|
||||
go 1.16
|
||||
go 1.20
|
||||
|
||||
require (
|
||||
github.com/alexedwards/argon2id v0.0.0-20200802152012-2464efd3196b
|
||||
github.com/fasthttp/router v1.2.4
|
||||
github.com/go-stack/stack v1.8.1 // indirect
|
||||
github.com/golang-migrate/migrate/v4 v4.14.2-0.20201125065321-a53e6fc42574
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/jackc/pgx/v4 v4.11.0
|
||||
github.com/johejo/golang-migrate-extra v0.0.0-20210217013041-51a992e50d16
|
||||
github.com/joho/godotenv v1.3.0
|
||||
github.com/klauspost/compress v1.15.1 // indirect
|
||||
github.com/minio/minio-go/v7 v7.0.5
|
||||
github.com/ulule/limiter/v3 v3.5.0
|
||||
github.com/valyala/fasthttp v1.16.0
|
||||
github.com/xdg-go/scram v1.1.1 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a // indirect
|
||||
go.mongodb.org/mongo-driver v1.8.4
|
||||
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 // indirect
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
|
||||
github.com/alexedwards/argon2id v0.0.0-20230305115115-4b3c3280a736
|
||||
github.com/go-chi/chi/v5 v5.0.8
|
||||
github.com/golang-migrate/migrate/v4 v4.16.1
|
||||
github.com/jackc/pgx/v5 v5.3.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/kelseyhightower/envconfig v1.4.0
|
||||
github.com/rs/zerolog v1.29.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.9.0 // indirect
|
||||
golang.org/x/sync v0.2.0 // indirect
|
||||
golang.org/x/sys v0.8.0 // indirect
|
||||
golang.org/x/text v0.9.0 // indirect
|
||||
)
|
||||
|
@ -1,124 +1,50 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/kelseyhightower/envconfig"
|
||||
"time"
|
||||
|
||||
"github.com/lus/pasty/internal/env"
|
||||
"github.com/lus/pasty/internal/shared"
|
||||
)
|
||||
|
||||
// Config represents the general application configuration structure
|
||||
type Config struct {
|
||||
WebAddress string
|
||||
StorageType shared.StorageType
|
||||
HastebinSupport bool
|
||||
IDLength int
|
||||
IDCharacters string
|
||||
ModificationTokens bool
|
||||
ModificationTokenMaster string
|
||||
ModificationTokenLength int
|
||||
ModificationTokenCharacters string
|
||||
RateLimit string
|
||||
LengthCap int
|
||||
AutoDelete *AutoDeleteConfig
|
||||
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
|
||||
File *FileConfig
|
||||
Postgres *PostgresConfig
|
||||
MongoDB *MongoDBConfig
|
||||
S3 *S3Config
|
||||
}
|
||||
|
||||
// AutoDeleteConfig represents the configuration specific for the AutoDelete behaviour
|
||||
type AutoDeleteConfig struct {
|
||||
Enabled bool
|
||||
Lifetime time.Duration
|
||||
TaskInterval time.Duration
|
||||
Enabled bool `default:"false"`
|
||||
Lifetime time.Duration `default:"720h"`
|
||||
TaskInterval time.Duration `default:"5m" split_words:"true"`
|
||||
}
|
||||
|
||||
// FileConfig represents the configuration specific for the file storage driver
|
||||
type FileConfig struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
// PostgresConfig represents the configuration specific for the Postgres storage driver
|
||||
type PostgresConfig struct {
|
||||
DSN string
|
||||
}
|
||||
|
||||
// MongoDBConfig represents the configuration specific for the MongoDB storage driver
|
||||
type MongoDBConfig struct {
|
||||
DSN string
|
||||
Database string
|
||||
Collection string
|
||||
}
|
||||
|
||||
// S3Config represents the configuration specific for the S3 storage driver
|
||||
type S3Config struct {
|
||||
Endpoint string
|
||||
AccessKeyID string
|
||||
SecretAccessKey string
|
||||
SecretToken string
|
||||
Secure bool
|
||||
Region string
|
||||
Bucket string
|
||||
}
|
||||
|
||||
// ReportConfig represents the configuration specific for the report system
|
||||
type ReportConfig struct {
|
||||
Reports bool
|
||||
ReportWebhook string
|
||||
ReportWebhookToken string
|
||||
Enabled bool `default:"false" split_words:"true"`
|
||||
WebhookURL string `split_words:"true"`
|
||||
WebhookToken string `split_words:"true"`
|
||||
}
|
||||
|
||||
// Current holds the currently loaded config
|
||||
var Current *Config
|
||||
type PostgresConfig struct {
|
||||
DSN string `default:"postgres://pasty:pasty@localhost/pasty"`
|
||||
}
|
||||
|
||||
// Load loads the current config from environment variables and an optional .env file
|
||||
func Load() {
|
||||
env.Load()
|
||||
|
||||
Current = &Config{
|
||||
WebAddress: env.MustString("WEB_ADDRESS", ":8080"),
|
||||
StorageType: shared.StorageType(strings.ToLower(env.MustString("STORAGE_TYPE", "file"))),
|
||||
HastebinSupport: env.MustBool("HASTEBIN_SUPPORT", false),
|
||||
IDLength: env.MustInt("ID_LENGTH", 6),
|
||||
IDCharacters: env.MustString("ID_CHARACTERS", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"),
|
||||
ModificationTokens: env.MustBool("MODIFICATION_TOKENS", env.MustBool("DELETION_TOKENS", true)), // ---
|
||||
ModificationTokenMaster: env.MustString("MODIFICATION_TOKEN_MASTER", env.MustString("DELETION_TOKEN_MASTER", "")), // - We don't want to destroy peoples old configuration
|
||||
ModificationTokenLength: env.MustInt("MODIFICATION_TOKEN_LENGTH", env.MustInt("DELETION_TOKEN_LENGTH", 12)), // ---
|
||||
ModificationTokenCharacters: env.MustString("MODIFICATION_TOKEN_CHARACTERS", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"),
|
||||
RateLimit: env.MustString("RATE_LIMIT", "30-M"),
|
||||
LengthCap: env.MustInt("LENGTH_CAP", 50_000),
|
||||
AutoDelete: &AutoDeleteConfig{
|
||||
Enabled: env.MustBool("AUTODELETE", false),
|
||||
Lifetime: env.MustDuration("AUTODELETE_LIFETIME", 720*time.Hour),
|
||||
TaskInterval: env.MustDuration("AUTODELETE_TASK_INTERVAL", 5*time.Minute),
|
||||
},
|
||||
Reports: &ReportConfig{
|
||||
Reports: env.MustBool("REPORTS", false),
|
||||
ReportWebhook: env.MustString("REPORT_WEBHOOK", ""),
|
||||
ReportWebhookToken: env.MustString("REPORT_WEBHOOK_TOKEN", ""),
|
||||
},
|
||||
File: &FileConfig{
|
||||
Path: env.MustString("STORAGE_FILE_PATH", "./data"),
|
||||
},
|
||||
Postgres: &PostgresConfig{
|
||||
DSN: env.MustString("STORAGE_POSTGRES_DSN", "postgres://pasty:pasty@localhost/pasty"),
|
||||
},
|
||||
MongoDB: &MongoDBConfig{
|
||||
DSN: env.MustString("STORAGE_MONGODB_CONNECTION_STRING", "mongodb://pasty:pasty@localhost/pasty"),
|
||||
Database: env.MustString("STORAGE_MONGODB_DATABASE", "pasty"),
|
||||
Collection: env.MustString("STORAGE_MONGODB_COLLECTION", "pastes"),
|
||||
},
|
||||
S3: &S3Config{
|
||||
Endpoint: env.MustString("STORAGE_S3_ENDPOINT", ""),
|
||||
AccessKeyID: env.MustString("STORAGE_S3_ACCESS_KEY_ID", ""),
|
||||
SecretAccessKey: env.MustString("STORAGE_S3_SECRET_ACCESS_KEY", ""),
|
||||
SecretToken: env.MustString("STORAGE_S3_SECRET_TOKEN", ""),
|
||||
Secure: env.MustBool("STORAGE_S3_SECURE", true),
|
||||
Region: env.MustString("STORAGE_S3_REGION", ""),
|
||||
Bucket: env.MustString("STORAGE_S3_BUCKET", "pasty"),
|
||||
},
|
||||
func Load() (*Config, error) {
|
||||
_ = godotenv.Overload()
|
||||
cfg := new(Config)
|
||||
if err := envconfig.Process("pasty", cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
42
internal/env/env.go
vendored
42
internal/env/env.go
vendored
@ -1,42 +0,0 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/lus/pasty/internal/static"
|
||||
)
|
||||
|
||||
// Load loads an optional .env file
|
||||
func Load() {
|
||||
godotenv.Load()
|
||||
}
|
||||
|
||||
// MustString returns the content of the environment variable with the given key or the given fallback
|
||||
func MustString(key, fallback string) string {
|
||||
value, found := os.LookupEnv(static.EnvironmentVariablePrefix + key)
|
||||
if !found {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// MustBool uses MustString and parses it into a boolean
|
||||
func MustBool(key string, fallback bool) bool {
|
||||
parsed, _ := strconv.ParseBool(MustString(key, strconv.FormatBool(fallback)))
|
||||
return parsed
|
||||
}
|
||||
|
||||
// MustInt uses MustString and parses it into an integer
|
||||
func MustInt(key string, fallback int) int {
|
||||
parsed, _ := strconv.Atoi(MustString(key, strconv.Itoa(fallback)))
|
||||
return parsed
|
||||
}
|
||||
|
||||
// MustDuration uses MustString and parses it into a duration
|
||||
func MustDuration(key string, fallback time.Duration) time.Duration {
|
||||
parsed, _ := time.ParseDuration(MustString(key, fallback.String()))
|
||||
return parsed
|
||||
}
|
13
internal/meta/metadata.go
Normal file
13
internal/meta/metadata.go
Normal file
@ -0,0 +1,13 @@
|
||||
package meta
|
||||
|
||||
import "strings"
|
||||
|
||||
const devEnvironmentName = "dev"
|
||||
|
||||
var (
|
||||
Environment = devEnvironmentName
|
||||
)
|
||||
|
||||
func IsProdEnvironment() bool {
|
||||
return strings.ToLower(Environment) != devEnvironmentName
|
||||
}
|
19
internal/pastes/id_generation.go
Normal file
19
internal/pastes/id_generation.go
Normal file
@ -0,0 +1,19 @@
|
||||
package pastes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/lus/pasty/internal/randx"
|
||||
)
|
||||
|
||||
func GenerateID(ctx context.Context, repo Repository, charset string, length int) (string, error) {
|
||||
for {
|
||||
id := randx.String(charset, length)
|
||||
existing, err := repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if existing == nil {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
}
|
31
internal/pastes/paste.go
Normal file
31
internal/pastes/paste.go
Normal file
@ -0,0 +1,31 @@
|
||||
package pastes
|
||||
|
||||
import "github.com/alexedwards/argon2id"
|
||||
|
||||
type Paste struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
ModificationToken string `json:"modificationToken,omitempty"`
|
||||
Created int64 `json:"created"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
|
||||
func (paste *Paste) HashModificationToken() error {
|
||||
if paste.ModificationToken == "" {
|
||||
return nil
|
||||
}
|
||||
hash, err := argon2id.CreateHash(paste.ModificationToken, argon2id.DefaultParams)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paste.ModificationToken = hash
|
||||
return nil
|
||||
}
|
||||
|
||||
func (paste *Paste) CheckModificationToken(modificationToken string) bool {
|
||||
if paste.ModificationToken == "" {
|
||||
return false
|
||||
}
|
||||
match, err := argon2id.ComparePasswordAndHash(modificationToken, paste.ModificationToken)
|
||||
return err == nil && match
|
||||
}
|
14
internal/pastes/repository.go
Normal file
14
internal/pastes/repository.go
Normal file
@ -0,0 +1,14 @@
|
||||
package pastes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
ListIDs(ctx context.Context) ([]string, error)
|
||||
FindByID(ctx context.Context, id string) (*Paste, error)
|
||||
Upsert(ctx context.Context, paste *Paste) error
|
||||
DeleteByID(ctx context.Context, id string) error
|
||||
DeleteOlderThan(ctx context.Context, age time.Duration) (int, error)
|
||||
}
|
14
internal/randx/string.go
Normal file
14
internal/randx/string.go
Normal file
@ -0,0 +1,14 @@
|
||||
package randx
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
// String generates a random string with the given length.
|
||||
func String(characters string, length int) string {
|
||||
bytes := make([]byte, length)
|
||||
for i := range bytes {
|
||||
bytes[i] = characters[rand.Int63()%int64(len(characters))]
|
||||
}
|
||||
return string(bytes)
|
||||
}
|
@ -1,57 +0,0 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/lus/pasty/internal/config"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// ReportRequest represents a report request sent to the report webhook
|
||||
type ReportRequest struct {
|
||||
Paste string `json:"paste"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// ReportResponse represents a report response received from the report webhook
|
||||
type ReportResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// SendReport sends a report request to the report webhook
|
||||
func SendReport(reportRequest *ReportRequest) (*ReportResponse, error) {
|
||||
request := fasthttp.AcquireRequest()
|
||||
defer fasthttp.ReleaseRequest(request)
|
||||
|
||||
response := fasthttp.AcquireResponse()
|
||||
defer fasthttp.ReleaseResponse(response)
|
||||
|
||||
request.Header.SetMethod(fasthttp.MethodPost)
|
||||
request.SetRequestURI(config.Current.Reports.ReportWebhook)
|
||||
if config.Current.Reports.ReportWebhookToken != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+config.Current.Reports.ReportWebhookToken)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(reportRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.SetBody(data)
|
||||
|
||||
if err := fasthttp.Do(request, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
status := response.StatusCode()
|
||||
if status < 200 || status > 299 {
|
||||
return nil, fmt.Errorf("the report webhook responded with an unexpected error: %d (%s)", status, string(response.Body()))
|
||||
}
|
||||
|
||||
reportResponse := new(ReportResponse)
|
||||
if err := json.Unmarshal(response.Body(), reportResponse); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reportResponse, nil
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/alexedwards/argon2id"
|
||||
)
|
||||
|
||||
// Paste represents a saved paste
|
||||
type Paste struct {
|
||||
ID string `json:"id" bson:"_id"`
|
||||
Content string `json:"content" bson:"content"`
|
||||
DeletionToken string `json:"deletionToken,omitempty" bson:"deletionToken"` // Required for legacy paste storage support
|
||||
ModificationToken string `json:"modificationToken,omitempty" bson:"modificationToken"`
|
||||
Created int64 `json:"created" bson:"created"`
|
||||
Metadata map[string]interface{} `json:"metadata" bson:"metadata"`
|
||||
}
|
||||
|
||||
// HashModificationToken hashes the current modification token of a paste
|
||||
func (paste *Paste) HashModificationToken() error {
|
||||
hash, err := argon2id.CreateHash(paste.ModificationToken, argon2id.DefaultParams)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paste.ModificationToken = hash
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckModificationToken checks whether or not the given modification token is correct
|
||||
func (paste *Paste) CheckModificationToken(modificationToken string) bool {
|
||||
// The modification token may be stored in the deletion token field in old pastes
|
||||
usedToken := paste.ModificationToken
|
||||
if usedToken == "" {
|
||||
usedToken = paste.DeletionToken
|
||||
if usedToken != "" {
|
||||
log.Println("WARNING: You seem to have pastes with the old 'deletionToken' field stored in your storage driver. Though this does not cause any issues right now, it may in the future. Consider some kind of migration.")
|
||||
}
|
||||
}
|
||||
|
||||
match, err := argon2id.ComparePasswordAndHash(modificationToken, usedToken)
|
||||
return err == nil && match
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
package shared
|
||||
|
||||
// StorageType represents a type of storage a paste can be stored with
|
||||
type StorageType string
|
||||
|
||||
const (
|
||||
StorageTypeFile = StorageType("file")
|
||||
StorageTypePostgres = StorageType("postgres")
|
||||
StorageTypeMongoDB = StorageType("mongodb")
|
||||
StorageTypeS3 = StorageType("s3")
|
||||
)
|
10
internal/slices/slice_utils.go
Normal file
10
internal/slices/slice_utils.go
Normal file
@ -0,0 +1,10 @@
|
||||
package slices
|
||||
|
||||
func Contains[T comparable](src []T, val T) bool {
|
||||
for _, elem := range src {
|
||||
if elem == val {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
package static
|
||||
|
||||
// These variables represent the values that may be changed using ldflags
|
||||
var (
|
||||
Version = "dev"
|
||||
EnvironmentVariablePrefix = "PASTY_"
|
||||
|
||||
// TempFrontendPath defines the path where pasty loads the web frontend from; it will be removed any time soon
|
||||
// TODO: Remove this when issue #37 is fixed
|
||||
TempFrontendPath = "./web"
|
||||
)
|
@ -1,59 +1,12 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/lus/pasty/internal/config"
|
||||
"github.com/lus/pasty/internal/shared"
|
||||
"github.com/lus/pasty/internal/storage/file"
|
||||
"github.com/lus/pasty/internal/storage/mongodb"
|
||||
"github.com/lus/pasty/internal/storage/postgres"
|
||||
"github.com/lus/pasty/internal/storage/s3"
|
||||
"context"
|
||||
"github.com/lus/pasty/internal/pastes"
|
||||
)
|
||||
|
||||
// Current holds the current storage driver
|
||||
var Current Driver
|
||||
|
||||
// Driver represents a storage driver
|
||||
type Driver interface {
|
||||
Initialize() error
|
||||
Terminate() error
|
||||
ListIDs() ([]string, error)
|
||||
Get(id string) (*shared.Paste, error)
|
||||
Save(paste *shared.Paste) error
|
||||
Delete(id string) error
|
||||
Cleanup() (int, error)
|
||||
}
|
||||
|
||||
// Load loads the current storage driver
|
||||
func Load() error {
|
||||
// Define the driver to use
|
||||
driver, err := GetDriver(config.Current.StorageType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Initialize the driver
|
||||
err = driver.Initialize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Current = driver
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDriver returns the driver with the given type if it exists
|
||||
func GetDriver(storageType shared.StorageType) (Driver, error) {
|
||||
switch storageType {
|
||||
case shared.StorageTypeFile:
|
||||
return new(file.FileDriver), nil
|
||||
case shared.StorageTypePostgres:
|
||||
return new(postgres.PostgresDriver), nil
|
||||
case shared.StorageTypeMongoDB:
|
||||
return new(mongodb.MongoDBDriver), nil
|
||||
case shared.StorageTypeS3:
|
||||
return new(s3.S3Driver), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid storage type '%s'", storageType)
|
||||
}
|
||||
Initialize(ctx context.Context) error
|
||||
Close() error
|
||||
Pastes() pastes.Repository
|
||||
}
|
||||
|
@ -1,145 +0,0 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lus/pasty/internal/config"
|
||||
"github.com/lus/pasty/internal/shared"
|
||||
)
|
||||
|
||||
// FileDriver represents the file storage driver
|
||||
type FileDriver struct {
|
||||
filePath string
|
||||
}
|
||||
|
||||
// Initialize initializes the file storage driver
|
||||
func (driver *FileDriver) Initialize() error {
|
||||
driver.filePath = config.Current.File.Path
|
||||
return os.MkdirAll(driver.filePath, os.ModePerm)
|
||||
}
|
||||
|
||||
// Terminate terminates the file storage driver (does nothing, because the file storage driver does not need any termination)
|
||||
func (driver *FileDriver) Terminate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListIDs returns a list of all existing paste IDs
|
||||
func (driver *FileDriver) ListIDs() ([]string, error) {
|
||||
// Define the IDs slice
|
||||
var ids []string
|
||||
|
||||
// Fill the IDs slice
|
||||
err := filepath.Walk(driver.filePath, func(path string, info os.FileInfo, err error) error {
|
||||
// Check if a walking error occurred
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Only count JSON files
|
||||
if !strings.HasSuffix(info.Name(), ".json") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decode the file name
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(info.Name(), ".json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Append the ID to the IDs slice
|
||||
ids = append(ids, string(decoded))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Return the IDs slice
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// Get loads a paste
|
||||
func (driver *FileDriver) Get(id string) (*shared.Paste, error) {
|
||||
// Read the file
|
||||
id = base64.StdEncoding.EncodeToString([]byte(id))
|
||||
data, err := ioutil.ReadFile(filepath.Join(driver.filePath, id+".json"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Unmarshal the file into a paste
|
||||
paste := new(shared.Paste)
|
||||
err = json.Unmarshal(data, &paste)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return paste, nil
|
||||
}
|
||||
|
||||
// Save saves a paste
|
||||
func (driver *FileDriver) Save(paste *shared.Paste) error {
|
||||
// Marshal the paste
|
||||
jsonBytes, err := json.Marshal(paste)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create the file to save the paste to
|
||||
id := base64.StdEncoding.EncodeToString([]byte(paste.ID))
|
||||
file, err := os.Create(filepath.Join(driver.filePath, id+".json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Write the JSON data into the file
|
||||
_, err = file.Write(jsonBytes)
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete deletes a paste
|
||||
func (driver *FileDriver) Delete(id string) error {
|
||||
id = base64.StdEncoding.EncodeToString([]byte(id))
|
||||
return os.Remove(filepath.Join(driver.filePath, id+".json"))
|
||||
}
|
||||
|
||||
// Cleanup cleans up the expired pastes
|
||||
func (driver *FileDriver) Cleanup() (int, error) {
|
||||
// Retrieve all paste IDs
|
||||
ids, err := driver.ListIDs()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Define the amount of deleted items
|
||||
deleted := 0
|
||||
|
||||
// Loop through all pastes
|
||||
for _, id := range ids {
|
||||
// Retrieve the paste object
|
||||
paste, err := driver.Get(id)
|
||||
if err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
|
||||
// Delete the paste if it is expired
|
||||
lifetime := config.Current.AutoDelete.Lifetime
|
||||
if paste.Created+int64(lifetime.Seconds()) < time.Now().Unix() {
|
||||
err = driver.Delete(id)
|
||||
if err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"github.com/lus/pasty/internal/config"
|
||||
"github.com/lus/pasty/internal/utils"
|
||||
)
|
||||
|
||||
// AcquireID generates a new unique ID
|
||||
func AcquireID() (string, error) {
|
||||
for {
|
||||
id := utils.RandomString(config.Current.IDCharacters, config.Current.IDLength)
|
||||
paste, err := Current.Get(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if paste == nil {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
}
|
@ -1,171 +0,0 @@
|
||||
package mongodb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/lus/pasty/internal/config"
|
||||
"github.com/lus/pasty/internal/shared"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
"go.mongodb.org/mongo-driver/mongo/readpref"
|
||||
)
|
||||
|
||||
// MongoDBDriver represents the MongoDB storage driver
|
||||
type MongoDBDriver struct {
|
||||
client *mongo.Client
|
||||
database string
|
||||
collection string
|
||||
}
|
||||
|
||||
// Initialize initializes the MongoDB storage driver
|
||||
func (driver *MongoDBDriver) Initialize() error {
|
||||
// Define the context for the following database operation
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Connect to the MongoDB host
|
||||
client, err := mongo.Connect(ctx, options.Client().ApplyURI(config.Current.MongoDB.DSN))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ping the MongoDB host
|
||||
err = client.Ping(ctx, readpref.Primary())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the driver attributes
|
||||
driver.client = client
|
||||
driver.database = config.Current.MongoDB.Database
|
||||
driver.collection = config.Current.MongoDB.Collection
|
||||
return nil
|
||||
}
|
||||
|
||||
// Terminate terminates the MongoDB storage driver
|
||||
func (driver *MongoDBDriver) Terminate() error {
|
||||
return driver.client.Disconnect(context.TODO())
|
||||
}
|
||||
|
||||
// ListIDs returns a list of all existing paste IDs
|
||||
func (driver *MongoDBDriver) ListIDs() ([]string, error) {
|
||||
// Define the collection to use for this database operation
|
||||
collection := driver.client.Database(driver.database).Collection(driver.collection)
|
||||
|
||||
// Define the context for the following database operation
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Retrieve all paste documents
|
||||
result, err := collection.Find(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decode all paste documents
|
||||
var pasteSlice []shared.Paste
|
||||
err = result.All(ctx, &pasteSlice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read and return the IDs of all paste objects
|
||||
var ids []string
|
||||
for _, paste := range pasteSlice {
|
||||
ids = append(ids, paste.ID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// Get loads a paste
|
||||
func (driver *MongoDBDriver) Get(id string) (*shared.Paste, error) {
|
||||
// Define the collection to use for this database operation
|
||||
collection := driver.client.Database(driver.database).Collection(driver.collection)
|
||||
|
||||
// Define the context for the following database operation
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Try to retrieve the corresponding paste document
|
||||
filter := bson.M{"_id": id}
|
||||
result := collection.FindOne(ctx, filter)
|
||||
err := result.Err()
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Return the retrieved paste object
|
||||
paste := new(shared.Paste)
|
||||
err = result.Decode(paste)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return paste, nil
|
||||
}
|
||||
|
||||
// Save saves a paste
|
||||
func (driver *MongoDBDriver) Save(paste *shared.Paste) error {
|
||||
// Define the collection to use for this database operation
|
||||
collection := driver.client.Database(driver.database).Collection(driver.collection)
|
||||
|
||||
// Define the context for the following database operation
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Upsert the paste object
|
||||
filter := bson.M{"_id": paste.ID}
|
||||
_, err := collection.UpdateOne(ctx, filter, bson.M{"$set": paste}, options.Update().SetUpsert(true))
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete deletes a paste
|
||||
func (driver *MongoDBDriver) Delete(id string) error {
|
||||
// Define the collection to use for this database operation
|
||||
collection := driver.client.Database(driver.database).Collection(driver.collection)
|
||||
|
||||
// Define the context for the following database operation
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Delete the document
|
||||
filter := bson.M{"_id": id}
|
||||
_, err := collection.DeleteOne(ctx, filter)
|
||||
return err
|
||||
}
|
||||
|
||||
// Cleanup cleans up the expired pastes
|
||||
func (driver *MongoDBDriver) Cleanup() (int, error) {
|
||||
// Retrieve all paste IDs
|
||||
ids, err := driver.ListIDs()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Define the amount of deleted items
|
||||
deleted := 0
|
||||
|
||||
// Loop through all pastes
|
||||
for _, id := range ids {
|
||||
// Retrieve the paste object
|
||||
paste, err := driver.Get(id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Delete the paste if it is expired
|
||||
lifetime := config.Current.AutoDelete.Lifetime
|
||||
if paste.Created+int64(lifetime.Seconds()) < time.Now().Unix() {
|
||||
err = driver.Delete(id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
74
internal/storage/postgres/driver.go
Normal file
74
internal/storage/postgres/driver.go
Normal file
@ -0,0 +1,74 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"errors"
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/lus/pasty/internal/pastes"
|
||||
"github.com/lus/pasty/internal/storage"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrations embed.FS
|
||||
|
||||
type Driver struct {
|
||||
dsn string
|
||||
connPool *pgxpool.Pool
|
||||
pastes *pasteRepository
|
||||
}
|
||||
|
||||
var _ storage.Driver = (*Driver)(nil)
|
||||
|
||||
func New(dsn string) *Driver {
|
||||
return &Driver{
|
||||
dsn: dsn,
|
||||
}
|
||||
}
|
||||
|
||||
func (driver *Driver) Initialize(ctx context.Context) error {
|
||||
pool, err := pgxpool.New(ctx, driver.dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info().Msg("Performing PostgreSQL database migrations...")
|
||||
source, err := iofs.New(migrations, "migrations")
|
||||
if err != nil {
|
||||
pool.Close()
|
||||
return err
|
||||
}
|
||||
migrator, err := migrate.NewWithSourceInstance("iofs", source, driver.dsn)
|
||||
if err != nil {
|
||||
pool.Close()
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_, _ = migrator.Close()
|
||||
}()
|
||||
if err := migrator.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
pool.Close()
|
||||
return err
|
||||
}
|
||||
log.Info().Msg("Successfully performed PostgreSQL database migrations.")
|
||||
|
||||
driver.connPool = pool
|
||||
driver.pastes = &pasteRepository{
|
||||
connPool: pool,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (driver *Driver) Close() error {
|
||||
driver.pastes = nil
|
||||
driver.connPool.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (driver *Driver) Pastes() pastes.Repository {
|
||||
return driver.pastes
|
||||
}
|
63
internal/storage/postgres/paste_repository.go
Normal file
63
internal/storage/postgres/paste_repository.go
Normal file
@ -0,0 +1,63 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/lus/pasty/internal/pastes"
|
||||
"time"
|
||||
)
|
||||
|
||||
type pasteRepository struct {
|
||||
connPool *pgxpool.Pool
|
||||
}
|
||||
|
||||
var _ pastes.Repository = (*pasteRepository)(nil)
|
||||
|
||||
func (repo *pasteRepository) ListIDs(ctx context.Context) ([]string, error) {
|
||||
rows, _ := repo.connPool.Query(ctx, "SELECT id FROM pastes")
|
||||
result, err := pgx.CollectRows(rows, pgx.RowTo[string])
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (repo *pasteRepository) FindByID(ctx context.Context, id string) (*pastes.Paste, error) {
|
||||
rows, _ := repo.connPool.Query(ctx, "SELECT * FROM pastes WHERE id = $1", id)
|
||||
result, err := pgx.CollectOneRow(rows, pgx.RowToAddrOfStructByPos[pastes.Paste])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (repo *pasteRepository) Upsert(ctx context.Context, paste *pastes.Paste) error {
|
||||
const query = `
|
||||
INSERT INTO pastes
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET content = excluded.content,
|
||||
"modificationToken" = excluded."modificationToken",
|
||||
metadata = excluded.metadata
|
||||
`
|
||||
_, err := repo.connPool.Exec(ctx, query, paste.ID, paste.Content, paste.ModificationToken, paste.Created, paste.Metadata)
|
||||
return err
|
||||
}
|
||||
|
||||
func (repo *pasteRepository) DeleteByID(ctx context.Context, id string) error {
|
||||
_, err := repo.connPool.Exec(ctx, "DELETE FROM pastes WHERE id = $1", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (repo *pasteRepository) DeleteOlderThan(ctx context.Context, age time.Duration) (int, error) {
|
||||
tag, err := repo.connPool.Exec(ctx, "DELETE FROM pastes WHERE created < $1", time.Now().Add(-age).Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
}
|
@ -1,127 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
"github.com/jackc/pgx/v4"
|
||||
"github.com/jackc/pgx/v4/pgxpool"
|
||||
"github.com/johejo/golang-migrate-extra/source/iofs"
|
||||
"github.com/lus/pasty/internal/config"
|
||||
"github.com/lus/pasty/internal/shared"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrations embed.FS
|
||||
|
||||
// PostgresDriver represents the Postgres storage driver
|
||||
type PostgresDriver struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// Initialize initializes the Postgres storage driver
|
||||
func (driver *PostgresDriver) Initialize() error {
|
||||
pool, err := pgxpool.Connect(context.Background(), config.Current.Postgres.DSN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
source, err := iofs.New(migrations, "migrations")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
migrator, err := migrate.NewWithSourceInstance("iofs", source, config.Current.Postgres.DSN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := migrator.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return err
|
||||
}
|
||||
|
||||
driver.pool = pool
|
||||
return nil
|
||||
}
|
||||
|
||||
// Terminate terminates the Postgres storage driver
|
||||
func (driver *PostgresDriver) Terminate() error {
|
||||
driver.pool.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListIDs returns a list of all existing paste IDs
|
||||
func (driver *PostgresDriver) ListIDs() ([]string, error) {
|
||||
query := "SELECT id FROM pastes"
|
||||
|
||||
rows, err := driver.pool.Query(context.Background(), query)
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// Get loads a paste
|
||||
func (driver *PostgresDriver) Get(id string) (*shared.Paste, error) {
|
||||
query := "SELECT * FROM pastes WHERE id = $1"
|
||||
|
||||
row := driver.pool.QueryRow(context.Background(), query, id)
|
||||
|
||||
paste := new(shared.Paste)
|
||||
if err := row.Scan(&paste.ID, &paste.Content, &paste.ModificationToken, &paste.Created, &paste.Metadata); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return paste, nil
|
||||
}
|
||||
|
||||
// Save saves a paste
|
||||
func (driver *PostgresDriver) Save(paste *shared.Paste) error {
|
||||
query := `
|
||||
INSERT INTO pastes (id, content, "modificationToken", created, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET content = excluded.content,
|
||||
"modificationToken" = excluded."modificationToken",
|
||||
created = excluded.created,
|
||||
metadata = excluded.metadata
|
||||
`
|
||||
|
||||
_, err := driver.pool.Exec(context.Background(), query, paste.ID, paste.Content, paste.ModificationToken, paste.Created, paste.Metadata)
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete deletes a paste
|
||||
func (driver *PostgresDriver) Delete(id string) error {
|
||||
query := "DELETE FROM pastes WHERE id = $1"
|
||||
|
||||
_, err := driver.pool.Exec(context.Background(), query, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Cleanup cleans up the expired pastes
|
||||
func (driver *PostgresDriver) Cleanup() (int, error) {
|
||||
query := "DELETE FROM pastes WHERE created < $1"
|
||||
|
||||
tag, err := driver.pool.Exec(context.Background(), query, time.Now().Add(-config.Current.AutoDelete.Lifetime).Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
}
|
@ -1,136 +0,0 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lus/pasty/internal/config"
|
||||
"github.com/lus/pasty/internal/shared"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
// S3Driver represents the AWS S3 storage driver
|
||||
type S3Driver struct {
|
||||
client *minio.Client
|
||||
bucket string
|
||||
}
|
||||
|
||||
// Initialize initializes the AWS S3 storage driver
|
||||
func (driver *S3Driver) Initialize() error {
|
||||
client, err := minio.New(config.Current.S3.Endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(config.Current.S3.AccessKeyID, config.Current.S3.SecretAccessKey, config.Current.S3.SecretToken),
|
||||
Secure: config.Current.S3.Secure,
|
||||
Region: config.Current.S3.Region,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
driver.client = client
|
||||
driver.bucket = config.Current.S3.Bucket
|
||||
return nil
|
||||
}
|
||||
|
||||
// Terminate terminates the AWS S3 storage driver (does nothing, because the AWS S3 storage driver does not need any termination)
|
||||
func (driver *S3Driver) Terminate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListIDs returns a list of all existing paste IDs
|
||||
func (driver *S3Driver) ListIDs() ([]string, error) {
|
||||
// Define the IDs slice
|
||||
var ids []string
|
||||
|
||||
// Fill the IDs slice
|
||||
channel := driver.client.ListObjects(context.Background(), driver.bucket, minio.ListObjectsOptions{})
|
||||
for object := range channel {
|
||||
if object.Err != nil {
|
||||
return nil, object.Err
|
||||
}
|
||||
ids = append(ids, strings.TrimSuffix(object.Key, ".json"))
|
||||
}
|
||||
|
||||
// Return the IDs slice
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// Get loads a paste
|
||||
func (driver *S3Driver) Get(id string) (*shared.Paste, error) {
|
||||
// Read the object
|
||||
object, err := driver.client.GetObject(context.Background(), driver.bucket, id+".json", minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := ioutil.ReadAll(object)
|
||||
if err != nil {
|
||||
if minio.ToErrorResponse(err).Code == "NoSuchKey" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Unmarshal the object into a paste
|
||||
paste := new(shared.Paste)
|
||||
err = json.Unmarshal(data, &paste)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return paste, nil
|
||||
}
|
||||
|
||||
// Save saves a paste
|
||||
func (driver *S3Driver) Save(paste *shared.Paste) error {
|
||||
// Marshal the paste
|
||||
jsonBytes, err := json.Marshal(paste)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Put the object
|
||||
reader := bytes.NewReader(jsonBytes)
|
||||
_, err = driver.client.PutObject(context.Background(), driver.bucket, paste.ID+".json", reader, reader.Size(), minio.PutObjectOptions{
|
||||
ContentType: "application/json",
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete deletes a paste
|
||||
func (driver *S3Driver) Delete(id string) error {
|
||||
return driver.client.RemoveObject(context.Background(), driver.bucket, id+".json", minio.RemoveObjectOptions{})
|
||||
}
|
||||
|
||||
// Cleanup cleans up the expired pastes
|
||||
func (driver *S3Driver) Cleanup() (int, error) {
|
||||
// Retrieve all paste IDs
|
||||
ids, err := driver.ListIDs()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Define the amount of deleted items
|
||||
deleted := 0
|
||||
|
||||
// Loop through all pastes
|
||||
for _, id := range ids {
|
||||
// Retrieve the paste object
|
||||
paste, err := driver.Get(id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Delete the paste if it is expired
|
||||
lifetime := config.Current.AutoDelete.Lifetime
|
||||
if paste.Created+int64(lifetime.Seconds()) < time.Now().Unix() {
|
||||
err = driver.Delete(id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RandomString returns a random string with the given length
|
||||
func RandomString(characters string, length int) string {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
bytes := make([]byte, length)
|
||||
for i := range bytes {
|
||||
bytes[i] = characters[rand.Int63()%int64(len(characters))]
|
||||
}
|
||||
return string(bytes)
|
||||
}
|
@ -1,72 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/lus/pasty/internal/config"
|
||||
"github.com/lus/pasty/internal/shared"
|
||||
"github.com/lus/pasty/internal/storage"
|
||||
"github.com/lus/pasty/internal/utils"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// HastebinSupportHandler handles the legacy hastebin requests
|
||||
func HastebinSupportHandler(ctx *fasthttp.RequestCtx) {
|
||||
// Check content length before reading body into memory
|
||||
if config.Current.LengthCap > 0 &&
|
||||
ctx.Request.Header.ContentLength() > config.Current.LengthCap {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
ctx.SetBodyString("request body length overflow")
|
||||
return
|
||||
}
|
||||
|
||||
// Define the paste content
|
||||
var content string
|
||||
if string(ctx.Request.Header.ContentType()) == "multipart/form-data" {
|
||||
content = string(ctx.FormValue("data"))
|
||||
} else {
|
||||
content = string(ctx.PostBody())
|
||||
}
|
||||
|
||||
// Acquire the paste ID
|
||||
id, err := storage.AcquireID()
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Create the paste object
|
||||
paste := &shared.Paste{
|
||||
ID: id,
|
||||
Content: content,
|
||||
Created: time.Now().Unix(),
|
||||
}
|
||||
|
||||
// Set a modification token
|
||||
if config.Current.ModificationTokens {
|
||||
paste.ModificationToken = utils.RandomString(config.Current.ModificationTokenCharacters, config.Current.ModificationTokenLength)
|
||||
|
||||
err = paste.HashModificationToken()
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Save the paste
|
||||
err = storage.Current.Save(paste)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Respond with the paste key
|
||||
jsonData, _ := json.Marshal(map[string]string{
|
||||
"key": paste.ID,
|
||||
})
|
||||
ctx.SetBody(jsonData)
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
package v1
|
||||
|
||||
import "github.com/lus/pasty/internal/shared"
|
||||
|
||||
type legacyPaste struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
DeletionToken string `json:"deletionToken,omitempty"`
|
||||
Created int64 `json:"created"`
|
||||
}
|
||||
|
||||
func legacyFromModern(paste *shared.Paste) *legacyPaste {
|
||||
deletionToken := paste.ModificationToken
|
||||
if deletionToken == "" {
|
||||
deletionToken = paste.DeletionToken
|
||||
}
|
||||
|
||||
return &legacyPaste{
|
||||
ID: paste.ID,
|
||||
Content: paste.Content,
|
||||
DeletionToken: deletionToken,
|
||||
Created: paste.Created,
|
||||
}
|
||||
}
|
@ -1,180 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/fasthttp/router"
|
||||
"github.com/lus/pasty/internal/config"
|
||||
"github.com/lus/pasty/internal/shared"
|
||||
"github.com/lus/pasty/internal/storage"
|
||||
"github.com/lus/pasty/internal/utils"
|
||||
limitFasthttp "github.com/ulule/limiter/v3/drivers/middleware/fasthttp"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// InitializePastesController initializes the '/v1/pastes/*' controller
|
||||
func InitializePastesController(group *router.Group, rateLimiterMiddleware *limitFasthttp.Middleware) {
|
||||
group.GET("/{id}", rateLimiterMiddleware.Handle(v1GetPaste))
|
||||
group.POST("", rateLimiterMiddleware.Handle(v1PostPaste))
|
||||
group.DELETE("/{id}", rateLimiterMiddleware.Handle(v1DeletePaste))
|
||||
}
|
||||
|
||||
// v1GetPaste handles the 'GET /v1/pastes/{id}' endpoint
|
||||
func v1GetPaste(ctx *fasthttp.RequestCtx) {
|
||||
// Read the ID
|
||||
id := ctx.UserValue("id").(string)
|
||||
|
||||
// Retrieve the paste
|
||||
paste, err := storage.Current.Get(id)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
if paste == nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusNotFound)
|
||||
ctx.SetBodyString("paste not found")
|
||||
return
|
||||
}
|
||||
legacyPaste := legacyFromModern(paste)
|
||||
legacyPaste.DeletionToken = ""
|
||||
|
||||
// Respond with the paste
|
||||
jsonData, err := json.Marshal(legacyPaste)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetBody(jsonData)
|
||||
}
|
||||
|
||||
// v1PostPaste handles the 'POST /v1/pastes' endpoint
|
||||
func v1PostPaste(ctx *fasthttp.RequestCtx) {
|
||||
// Check content length before reading body into memory
|
||||
if config.Current.LengthCap > 0 &&
|
||||
ctx.Request.Header.ContentLength() > config.Current.LengthCap {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
ctx.SetBodyString("request body length overflow")
|
||||
return
|
||||
}
|
||||
|
||||
// Unmarshal the body
|
||||
values := make(map[string]string)
|
||||
err := json.Unmarshal(ctx.PostBody(), &values)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
ctx.SetBodyString("invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the content of the paste
|
||||
if values["content"] == "" {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
ctx.SetBodyString("missing 'content' field")
|
||||
return
|
||||
}
|
||||
|
||||
// Acquire the paste ID
|
||||
id, err := storage.AcquireID()
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Create the paste object
|
||||
paste := &shared.Paste{
|
||||
ID: id,
|
||||
Content: values["content"],
|
||||
Created: time.Now().Unix(),
|
||||
}
|
||||
|
||||
// Set a modification token
|
||||
modificationToken := ""
|
||||
if config.Current.ModificationTokens {
|
||||
modificationToken = utils.RandomString(config.Current.ModificationTokenCharacters, config.Current.ModificationTokenLength)
|
||||
paste.ModificationToken = modificationToken
|
||||
|
||||
err = paste.HashModificationToken()
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Save the paste
|
||||
err = storage.Current.Save(paste)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Respond with the paste
|
||||
pasteCopy := legacyFromModern(paste)
|
||||
pasteCopy.DeletionToken = modificationToken
|
||||
jsonData, err := json.Marshal(pasteCopy)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetBody(jsonData)
|
||||
}
|
||||
|
||||
// v1DeletePaste handles the 'DELETE /v1/pastes/{id}'
|
||||
func v1DeletePaste(ctx *fasthttp.RequestCtx) {
|
||||
// Read the ID
|
||||
id := ctx.UserValue("id").(string)
|
||||
|
||||
// Unmarshal the body
|
||||
values := make(map[string]string)
|
||||
err := json.Unmarshal(ctx.PostBody(), &values)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
ctx.SetBodyString("invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the modification token of the paste
|
||||
modificationToken := values["deletionToken"]
|
||||
if modificationToken == "" {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
ctx.SetBodyString("missing 'deletionToken' field")
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve the paste
|
||||
paste, err := storage.Current.Get(id)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
if paste == nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusNotFound)
|
||||
ctx.SetBodyString("paste not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the modification token is correct
|
||||
if (config.Current.ModificationTokenMaster == "" || modificationToken != config.Current.ModificationTokenMaster) && !paste.CheckModificationToken(modificationToken) {
|
||||
ctx.SetStatusCode(fasthttp.StatusForbidden)
|
||||
ctx.SetBodyString("invalid deletion token")
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the paste
|
||||
err = storage.Current.Delete(paste.ID)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Respond with 'ok'
|
||||
ctx.SetBodyString("ok")
|
||||
}
|
@ -1,274 +0,0 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fasthttp/router"
|
||||
"github.com/lus/pasty/internal/config"
|
||||
"github.com/lus/pasty/internal/report"
|
||||
"github.com/lus/pasty/internal/shared"
|
||||
"github.com/lus/pasty/internal/storage"
|
||||
"github.com/lus/pasty/internal/utils"
|
||||
limitFasthttp "github.com/ulule/limiter/v3/drivers/middleware/fasthttp"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// InitializePastesController initializes the '/v2/pastes/*' controller
|
||||
func InitializePastesController(group *router.Group, rateLimiterMiddleware *limitFasthttp.Middleware) {
|
||||
// moms spaghetti
|
||||
group.GET("/{id}", rateLimiterMiddleware.Handle(middlewareInjectPaste(endpointGetPaste)))
|
||||
group.POST("", rateLimiterMiddleware.Handle(endpointCreatePaste))
|
||||
group.PATCH("/{id}", rateLimiterMiddleware.Handle(middlewareInjectPaste(middlewareValidateModificationToken(endpointModifyPaste))))
|
||||
group.DELETE("/{id}", rateLimiterMiddleware.Handle(middlewareInjectPaste(middlewareValidateModificationToken(endpointDeletePaste))))
|
||||
|
||||
if config.Current.Reports.Reports {
|
||||
group.POST("/{id}/report", rateLimiterMiddleware.Handle(middlewareInjectPaste(endpointReportPaste)))
|
||||
}
|
||||
}
|
||||
|
||||
// middlewareInjectPaste retrieves and injects the paste with the specified ID
|
||||
func middlewareInjectPaste(next fasthttp.RequestHandler) fasthttp.RequestHandler {
|
||||
return func(ctx *fasthttp.RequestCtx) {
|
||||
pasteID := ctx.UserValue("id").(string)
|
||||
|
||||
paste, err := storage.Current.Get(pasteID)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
if paste == nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusNotFound)
|
||||
ctx.SetBodyString("paste not found")
|
||||
return
|
||||
}
|
||||
|
||||
if paste.Metadata == nil {
|
||||
paste.Metadata = map[string]interface{}{}
|
||||
}
|
||||
|
||||
ctx.SetUserValue("_paste", paste)
|
||||
|
||||
next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// middlewareValidateModificationToken extracts and validates a given modification token for an injected paste
|
||||
func middlewareValidateModificationToken(next fasthttp.RequestHandler) fasthttp.RequestHandler {
|
||||
return func(ctx *fasthttp.RequestCtx) {
|
||||
paste := ctx.UserValue("_paste").(*shared.Paste)
|
||||
|
||||
authHeaderSplit := strings.SplitN(string(ctx.Request.Header.Peek("Authorization")), " ", 2)
|
||||
if len(authHeaderSplit) < 2 || authHeaderSplit[0] != "Bearer" {
|
||||
ctx.SetStatusCode(fasthttp.StatusUnauthorized)
|
||||
ctx.SetBodyString("unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
modificationToken := authHeaderSplit[1]
|
||||
if config.Current.ModificationTokenMaster != "" && modificationToken == config.Current.ModificationTokenMaster {
|
||||
next(ctx)
|
||||
return
|
||||
}
|
||||
valid := paste.CheckModificationToken(modificationToken)
|
||||
if !valid {
|
||||
ctx.SetStatusCode(fasthttp.StatusUnauthorized)
|
||||
ctx.SetBodyString("unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// endpointGetPaste handles the 'GET /v2/pastes/{id}' endpoint
|
||||
func endpointGetPaste(ctx *fasthttp.RequestCtx) {
|
||||
paste := ctx.UserValue("_paste").(*shared.Paste)
|
||||
paste.DeletionToken = ""
|
||||
paste.ModificationToken = ""
|
||||
|
||||
jsonData, err := json.Marshal(paste)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetBody(jsonData)
|
||||
}
|
||||
|
||||
type endpointCreatePastePayload struct {
|
||||
Content string `json:"content"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
// endpointCreatePaste handles the 'POST /v2/pastes' endpoint
|
||||
func endpointCreatePaste(ctx *fasthttp.RequestCtx) {
|
||||
// Read, parse and validate the request payload
|
||||
payload := new(endpointCreatePastePayload)
|
||||
if err := json.Unmarshal(ctx.PostBody(), payload); err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
if payload.Content == "" {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
ctx.SetBodyString("missing paste content")
|
||||
return
|
||||
}
|
||||
if config.Current.LengthCap > 0 && len(payload.Content) > config.Current.LengthCap {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
ctx.SetBodyString("too large paste content")
|
||||
return
|
||||
}
|
||||
|
||||
// Acquire a new paste ID
|
||||
id, err := storage.AcquireID()
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare the paste object
|
||||
if payload.Metadata == nil {
|
||||
payload.Metadata = map[string]interface{}{}
|
||||
}
|
||||
paste := &shared.Paste{
|
||||
ID: id,
|
||||
Content: payload.Content,
|
||||
Created: time.Now().Unix(),
|
||||
Metadata: payload.Metadata,
|
||||
}
|
||||
|
||||
// Create a new modification token if enabled
|
||||
modificationToken := ""
|
||||
if config.Current.ModificationTokens {
|
||||
modificationToken = utils.RandomString(config.Current.ModificationTokenCharacters, config.Current.ModificationTokenLength)
|
||||
paste.ModificationToken = modificationToken
|
||||
|
||||
err = paste.HashModificationToken()
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Save the paste
|
||||
err = storage.Current.Save(paste)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Respond with the paste
|
||||
pasteCopy := *paste
|
||||
pasteCopy.ModificationToken = modificationToken
|
||||
jsonData, err := json.Marshal(pasteCopy)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetStatusCode(fasthttp.StatusCreated)
|
||||
ctx.SetBody(jsonData)
|
||||
}
|
||||
|
||||
type endpointModifyPastePayload struct {
|
||||
Content *string `json:"content"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
// endpointModifyPaste handles the 'PATCH /v2/pastes/{id}' endpoint
|
||||
func endpointModifyPaste(ctx *fasthttp.RequestCtx) {
|
||||
// Read, parse and validate the request payload
|
||||
payload := new(endpointModifyPastePayload)
|
||||
if err := json.Unmarshal(ctx.PostBody(), payload); err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
if payload.Content != nil && *payload.Content == "" {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
ctx.SetBodyString("missing paste content")
|
||||
return
|
||||
}
|
||||
if payload.Content != nil && config.Current.LengthCap > 0 && len(*payload.Content) > config.Current.LengthCap {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
ctx.SetBodyString("too large paste content")
|
||||
return
|
||||
}
|
||||
|
||||
// Modify the paste itself
|
||||
paste := ctx.UserValue("_paste").(*shared.Paste)
|
||||
if payload.Content != nil {
|
||||
paste.Content = *payload.Content
|
||||
}
|
||||
if payload.Metadata != nil {
|
||||
for key, value := range payload.Metadata {
|
||||
if value == nil {
|
||||
delete(paste.Metadata, key)
|
||||
continue
|
||||
}
|
||||
paste.Metadata[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Save the modified paste
|
||||
if err := storage.Current.Save(paste); err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// endpointDeletePaste handles the 'DELETE /v2/pastes/{id}' endpoint
|
||||
func endpointDeletePaste(ctx *fasthttp.RequestCtx) {
|
||||
paste := ctx.UserValue("_paste").(*shared.Paste)
|
||||
if err := storage.Current.Delete(paste.ID); err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type endpointReportPastePayload struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func endpointReportPaste(ctx *fasthttp.RequestCtx) {
|
||||
// Read, parse and validate the request payload
|
||||
payload := new(endpointReportPastePayload)
|
||||
if err := json.Unmarshal(ctx.PostBody(), payload); err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
if payload.Reason == "" {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
ctx.SetBodyString("missing report reason")
|
||||
return
|
||||
}
|
||||
|
||||
request := &report.ReportRequest{
|
||||
Paste: ctx.UserValue("_paste").(*shared.Paste).ID,
|
||||
Reason: payload.Reason,
|
||||
}
|
||||
response, err := report.SendReport(request)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetBody(jsonData)
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
package web
|
||||
|
||||
// nilLogger represents a logger that does not print anything
|
||||
type nilLogger struct {
|
||||
}
|
||||
|
||||
// Printf prints nothing
|
||||
func (logger *nilLogger) Printf(string, ...interface{}) {
|
||||
}
|
33
internal/web/response_writer.go
Normal file
33
internal/web/response_writer.go
Normal file
@ -0,0 +1,33 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func writeErr(writer http.ResponseWriter, err error) {
|
||||
writeString(writer, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
func writeString(writer http.ResponseWriter, status int, value string) {
|
||||
writer.WriteHeader(status)
|
||||
writer.Write([]byte(value))
|
||||
}
|
||||
|
||||
func writeJSON(writer http.ResponseWriter, status int, value any) error {
|
||||
jsonData, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
writer.WriteHeader(status)
|
||||
writer.Write(jsonData)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSONOrErr(writer http.ResponseWriter, status int, value any) {
|
||||
if err := writeJSON(writer, status, value); err != nil {
|
||||
writeErr(writer, err)
|
||||
}
|
||||
}
|
49
internal/web/server.go
Normal file
49
internal/web/server.go
Normal file
@ -0,0 +1,49 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/lus/pasty/internal/storage"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
// The address the web server should listen to.
|
||||
Address string
|
||||
|
||||
// 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 length of newly generated paste IDs.
|
||||
PasteIDLength int
|
||||
// The charset to use when generating new paste IDs.
|
||||
PasteIDCharset string
|
||||
|
||||
// The maximum length of newly generated pastes.
|
||||
PasteLengthCap int
|
||||
|
||||
// Whether modification tokens are enabled.
|
||||
ModificationTokensEnabled bool
|
||||
// The length of newly generated modification tokens.
|
||||
ModificationTokenLength int
|
||||
// The charset to use when generating new modification tokens.
|
||||
ModificationTokenCharset string
|
||||
|
||||
// The administration tokens.
|
||||
AdminTokens []string
|
||||
}
|
||||
|
||||
func (server *Server) Start() error {
|
||||
router := chi.NewRouter()
|
||||
|
||||
// Register the paste API endpoints
|
||||
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.Delete("/api/v2/pastes/{paste_id}", server.v2EndpointDeletePaste)
|
||||
|
||||
return http.ListenAndServe(server.Address, router)
|
||||
}
|
70
internal/web/v2_end_create_paste.go
Normal file
70
internal/web/v2_end_create_paste.go
Normal file
@ -0,0 +1,70 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/lus/pasty/internal/pastes"
|
||||
"github.com/lus/pasty/internal/randx"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type v2EndpointCreatePastePayload struct {
|
||||
Content string `json:"content"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
|
||||
func (server *Server) v2EndpointCreatePaste(writer http.ResponseWriter, request *http.Request) {
|
||||
// Read, parse and validate the request payload
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
writeErr(writer, err)
|
||||
return
|
||||
}
|
||||
payload := new(v2EndpointCreatePastePayload)
|
||||
if err := json.Unmarshal(body, payload); err != nil {
|
||||
writeErr(writer, err)
|
||||
return
|
||||
}
|
||||
if payload.Content == "" {
|
||||
writeString(writer, http.StatusBadRequest, "missing paste content")
|
||||
return
|
||||
}
|
||||
if server.PasteLengthCap > 0 && len(payload.Content) > server.PasteLengthCap {
|
||||
writeString(writer, http.StatusBadRequest, "too large paste content")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := pastes.GenerateID(request.Context(), server.Storage.Pastes(), server.PasteIDCharset, server.PasteIDLength)
|
||||
if err != nil {
|
||||
writeErr(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
paste := &pastes.Paste{
|
||||
ID: id,
|
||||
Content: payload.Content,
|
||||
Created: time.Now().Unix(),
|
||||
Metadata: payload.Metadata,
|
||||
}
|
||||
|
||||
modificationToken := ""
|
||||
if server.ModificationTokensEnabled {
|
||||
modificationToken = randx.String(server.ModificationTokenCharset, server.ModificationTokenLength)
|
||||
paste.ModificationToken = modificationToken
|
||||
|
||||
if err := paste.HashModificationToken(); err != nil {
|
||||
writeErr(writer, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := server.Storage.Pastes().Upsert(request.Context(), paste); err != nil {
|
||||
writeErr(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
cpy := *paste
|
||||
cpy.ModificationToken = modificationToken
|
||||
writeJSONOrErr(writer, http.StatusCreated, cpy)
|
||||
}
|
18
internal/web/v2_end_delete_paste.go
Normal file
18
internal/web/v2_end_delete_paste.go
Normal file
@ -0,0 +1,18 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"github.com/lus/pasty/internal/pastes"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (server *Server) v2EndpointDeletePaste(writer http.ResponseWriter, request *http.Request) {
|
||||
paste, ok := request.Context().Value("paste").(*pastes.Paste)
|
||||
if !ok {
|
||||
writeString(writer, http.StatusInternalServerError, "missing paste object")
|
||||
return
|
||||
}
|
||||
|
||||
if err := server.Storage.Pastes().DeleteByID(request.Context(), paste.ID); err != nil {
|
||||
writeErr(writer, err)
|
||||
}
|
||||
}
|
18
internal/web/v2_end_get_paste.go
Normal file
18
internal/web/v2_end_get_paste.go
Normal file
@ -0,0 +1,18 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"github.com/lus/pasty/internal/pastes"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
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")
|
||||
return
|
||||
}
|
||||
|
||||
cpy := *paste
|
||||
cpy.ModificationToken = ""
|
||||
writeJSONOrErr(writer, http.StatusOK, cpy)
|
||||
}
|
60
internal/web/v2_end_modify_paste.go
Normal file
60
internal/web/v2_end_modify_paste.go
Normal file
@ -0,0 +1,60 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/lus/pasty/internal/pastes"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type v2EndpointModifyPastePayload struct {
|
||||
Content *string `json:"content"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
|
||||
func (server *Server) v2EndpointModifyPaste(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(writer, err)
|
||||
return
|
||||
}
|
||||
payload := new(v2EndpointModifyPastePayload)
|
||||
if err := json.Unmarshal(body, payload); err != nil {
|
||||
writeErr(writer, err)
|
||||
return
|
||||
}
|
||||
if payload.Content != nil && *payload.Content == "" {
|
||||
writeString(writer, http.StatusBadRequest, "missing paste content")
|
||||
return
|
||||
}
|
||||
if payload.Content != nil && server.PasteLengthCap > 0 && len(*payload.Content) > server.PasteLengthCap {
|
||||
writeString(writer, http.StatusBadRequest, "too large paste content")
|
||||
return
|
||||
}
|
||||
|
||||
// Modify the paste itself
|
||||
if payload.Content != nil {
|
||||
paste.Content = *payload.Content
|
||||
}
|
||||
if payload.Metadata != nil {
|
||||
for key, value := range payload.Metadata {
|
||||
if value == nil {
|
||||
delete(paste.Metadata, key)
|
||||
continue
|
||||
}
|
||||
paste.Metadata[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Save the modified paste
|
||||
if err := server.Storage.Pastes().Upsert(request.Context(), paste); err != nil {
|
||||
writeErr(writer, err)
|
||||
}
|
||||
}
|
37
internal/web/v2_mid_authorize.go
Normal file
37
internal/web/v2_mid_authorize.go
Normal file
@ -0,0 +1,37 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"github.com/lus/pasty/internal/pastes"
|
||||
"github.com/lus/pasty/internal/slices"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (server *Server) v2MiddlewareAuthorize(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
paste, ok := request.Context().Value("paste").(*pastes.Paste)
|
||||
if !ok {
|
||||
writeString(writer, http.StatusInternalServerError, "missing paste object")
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := strings.SplitN(request.Header.Get("Authorization"), " ", 2)
|
||||
if len(authHeader) != 2 || authHeader[0] != "Bearer" {
|
||||
writeString(writer, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
isAdmin := slices.Contains(server.AdminTokens, authHeader[1])
|
||||
if isAdmin {
|
||||
next.ServeHTTP(writer, request)
|
||||
return
|
||||
}
|
||||
|
||||
if !server.ModificationTokensEnabled || !paste.CheckModificationToken(authHeader[1]) {
|
||||
writeString(writer, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(writer, request)
|
||||
})
|
||||
}
|
38
internal/web/v2_mid_inject_paste.go
Normal file
38
internal/web/v2_mid_inject_paste.go
Normal file
@ -0,0 +1,38 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (server *Server) v2MiddlewareInjectPaste(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
pasteID := strings.TrimSpace(chi.URLParam(request, "paste_id"))
|
||||
if pasteID == "" {
|
||||
writeString(writer, http.StatusNotFound, "paste not found")
|
||||
return
|
||||
}
|
||||
|
||||
paste, err := server.Storage.Pastes().FindByID(request.Context(), pasteID)
|
||||
if err != nil {
|
||||
if pasteID == "" {
|
||||
writeErr(writer, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if paste == nil {
|
||||
writeString(writer, http.StatusNotFound, "paste not found")
|
||||
return
|
||||
}
|
||||
|
||||
if paste.Metadata == nil {
|
||||
paste.Metadata = make(map[string]any)
|
||||
}
|
||||
|
||||
request = request.WithContext(context.WithValue(request.Context(), "paste", paste))
|
||||
|
||||
next.ServeHTTP(writer, request)
|
||||
})
|
||||
}
|
@ -1,150 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
routing "github.com/fasthttp/router"
|
||||
"github.com/lus/pasty/internal/config"
|
||||
"github.com/lus/pasty/internal/static"
|
||||
"github.com/lus/pasty/internal/storage"
|
||||
v1 "github.com/lus/pasty/internal/web/controllers/v1"
|
||||
v2 "github.com/lus/pasty/internal/web/controllers/v2"
|
||||
"github.com/ulule/limiter/v3"
|
||||
limitFasthttp "github.com/ulule/limiter/v3/drivers/middleware/fasthttp"
|
||||
"github.com/ulule/limiter/v3/drivers/store/memory"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// Serve serves the web resources
|
||||
func Serve() error {
|
||||
// Create the router
|
||||
router := routing.New()
|
||||
|
||||
// Define the 404 handler
|
||||
router.NotFound = func(ctx *fasthttp.RequestCtx) {
|
||||
ctx.SetStatusCode(fasthttp.StatusNotFound)
|
||||
ctx.SetBodyString("not found")
|
||||
}
|
||||
|
||||
// Route the frontend requests
|
||||
frontend := frontendHandler()
|
||||
raw := rawHandler()
|
||||
router.GET("/{path:*}", func(ctx *fasthttp.RequestCtx) {
|
||||
path := string(ctx.Path())
|
||||
if !strings.HasPrefix(path, "/api") && (strings.Count(path, "/") == 1 || strings.HasPrefix(path, "/assets")) {
|
||||
if strings.HasPrefix(path, "/assets/js/") {
|
||||
ctx.SetContentType("text/javascript")
|
||||
}
|
||||
frontend(ctx)
|
||||
return
|
||||
} else if strings.HasSuffix(strings.TrimSuffix(path, "/"), "/raw") {
|
||||
raw(ctx)
|
||||
return
|
||||
}
|
||||
router.NotFound(ctx)
|
||||
})
|
||||
|
||||
// Set up the rate limiter
|
||||
rate, err := limiter.NewRateFromFormatted(config.Current.RateLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rateLimiter := limiter.New(memory.NewStore(), rate)
|
||||
rateLimiterMiddleware := limitFasthttp.NewMiddleware(rateLimiter)
|
||||
|
||||
// Route the API endpoints
|
||||
apiRoute := router.Group("/api")
|
||||
{
|
||||
v1Route := apiRoute.Group("/v1")
|
||||
{
|
||||
v1Route.GET("/info", func(ctx *fasthttp.RequestCtx) {
|
||||
jsonData, _ := json.Marshal(map[string]interface{}{
|
||||
"version": static.Version,
|
||||
"deletionTokens": config.Current.ModificationTokens,
|
||||
})
|
||||
ctx.SetBody(jsonData)
|
||||
})
|
||||
v1.InitializePastesController(v1Route.Group("/pastes"), rateLimiterMiddleware)
|
||||
}
|
||||
|
||||
v2Route := apiRoute.Group("/v2")
|
||||
{
|
||||
pasteLifetime := int64(-1)
|
||||
if config.Current.AutoDelete.Enabled {
|
||||
pasteLifetime = config.Current.AutoDelete.Lifetime.Milliseconds()
|
||||
}
|
||||
v2Route.GET("/info", func(ctx *fasthttp.RequestCtx) {
|
||||
jsonData, _ := json.Marshal(map[string]interface{}{
|
||||
"version": static.Version,
|
||||
"modificationTokens": config.Current.ModificationTokens,
|
||||
"reports": config.Current.Reports.Reports,
|
||||
"pasteLifetime": pasteLifetime,
|
||||
})
|
||||
ctx.SetBody(jsonData)
|
||||
})
|
||||
v2.InitializePastesController(v2Route.Group("/pastes"), rateLimiterMiddleware)
|
||||
}
|
||||
}
|
||||
|
||||
// Route the hastebin documents route if hastebin support is enabled
|
||||
if config.Current.HastebinSupport {
|
||||
router.POST("/documents", rateLimiterMiddleware.Handle(v1.HastebinSupportHandler))
|
||||
}
|
||||
|
||||
// Serve the web resources
|
||||
return (&fasthttp.Server{
|
||||
Handler: func(ctx *fasthttp.RequestCtx) {
|
||||
// Add the CORS headers
|
||||
ctx.Response.Header.Set("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS")
|
||||
ctx.Response.Header.Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
// Call the router handler
|
||||
router.Handler(ctx)
|
||||
},
|
||||
Logger: new(nilLogger),
|
||||
}).ListenAndServe(config.Current.WebAddress)
|
||||
}
|
||||
|
||||
// frontendHandler handles the frontend routing
|
||||
func frontendHandler() fasthttp.RequestHandler {
|
||||
// Create the file server
|
||||
fs := &fasthttp.FS{
|
||||
Root: static.TempFrontendPath,
|
||||
IndexNames: []string{"index.html"},
|
||||
CacheDuration: 0,
|
||||
}
|
||||
fs.PathNotFound = func(ctx *fasthttp.RequestCtx) {
|
||||
if strings.HasPrefix(string(ctx.Path()), "/assets") {
|
||||
ctx.SetStatusCode(fasthttp.StatusNotFound)
|
||||
ctx.SetBodyString("not found")
|
||||
return
|
||||
}
|
||||
ctx.SendFile(filepath.Join(fs.Root, "index.html"))
|
||||
}
|
||||
return fs.NewRequestHandler()
|
||||
}
|
||||
|
||||
func rawHandler() fasthttp.RequestHandler {
|
||||
return func(ctx *fasthttp.RequestCtx) {
|
||||
path := string(ctx.Path())
|
||||
pathSanitized := strings.TrimPrefix(strings.TrimSuffix(path, "/"), "/")
|
||||
pasteID := strings.TrimSuffix(pathSanitized, "/raw")
|
||||
|
||||
paste, err := storage.Current.Get(pasteID)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
ctx.SetBodyString(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if paste == nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusNotFound)
|
||||
ctx.SetBodyString("paste not found")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetBodyString(paste.Content)
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
const API_BASE_URL = location.protocol + "//" + location.host + "/api/v2";
|
||||
const API_BASE_URL = location.protocol + "//" + location.host + "/web/v2";
|
||||
|
||||
export async function getAPIInformation() {
|
||||
return fetch(API_BASE_URL + "/info");
|
||||
|
@ -64,20 +64,20 @@ export async function initialize() {
|
||||
}
|
||||
|
||||
if (location.pathname !== "/") {
|
||||
// Extract the paste data (ID and language)
|
||||
// Extract the pastes data (ID and language)
|
||||
const split = location.pathname.replace("/", "").split(".");
|
||||
const pasteID = split[0];
|
||||
const language = split[1];
|
||||
|
||||
// Try to retrieve the paste data from the API
|
||||
// Try to retrieve the pastes data from the API
|
||||
const response = await API.getPaste(pasteID);
|
||||
if (!response.ok) {
|
||||
Notifications.error("Could not load paste: <b>" + await response.text() + "</b>");
|
||||
Notifications.error("Could not load pastes: <b>" + await response.text() + "</b>");
|
||||
setTimeout(() => location.replace(location.protocol + "//" + location.host), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the persistent paste data
|
||||
// Set the persistent pastes data
|
||||
PASTE_ID = pasteID;
|
||||
LANGUAGE = language;
|
||||
|
||||
@ -95,7 +95,7 @@ export async function initialize() {
|
||||
ENCRYPTION_IV = json.metadata.pf_encryption.iv;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
Notifications.error("Could not decrypt paste; make sure the decryption key is correct.");
|
||||
Notifications.error("Could not decrypt pastes; make sure the decryption key is correct.");
|
||||
setTimeout(() => location.replace(location.protocol + "//" + location.host), 3000);
|
||||
return;
|
||||
}
|
||||
@ -104,7 +104,7 @@ export async function initialize() {
|
||||
// Fill the code block with the just received data
|
||||
updateCode();
|
||||
} else {
|
||||
// Give the user the opportunity to paste his code
|
||||
// Give the user the opportunity to pastes his code
|
||||
INPUT_ELEMENT.classList.remove("hidden");
|
||||
INPUT_ELEMENT.focus();
|
||||
LIFETIME_CONTAINER_ELEMENT.classList.remove("hidden");
|
||||
@ -138,7 +138,7 @@ async function loadAPIInformation() {
|
||||
// Display the API version
|
||||
document.getElementById("version").innerText = API_INFORMATION.version;
|
||||
|
||||
// Display the paste lifetime
|
||||
// Display the pastes lifetime
|
||||
document.getElementById("lifetime").innerText = Duration.format(API_INFORMATION.pasteLifetime);
|
||||
}
|
||||
|
||||
@ -289,7 +289,7 @@ function setupButtonFunctionality() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Encrypt the paste if needed
|
||||
// Encrypt the pastes if needed
|
||||
let value = INPUT_ELEMENT.value;
|
||||
let metadata;
|
||||
let key;
|
||||
@ -305,20 +305,20 @@ function setupButtonFunctionality() {
|
||||
key = encrypted.key;
|
||||
}
|
||||
|
||||
// Try to create the paste
|
||||
// Try to create the pastes
|
||||
const response = await API.createPaste(value, metadata);
|
||||
if (!response.ok) {
|
||||
Notifications.error("Error while creating paste: <b>" + await response.text() + "</b>");
|
||||
Notifications.error("Error while creating pastes: <b>" + await response.text() + "</b>");
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// Display the modification token if provided
|
||||
if (data.modificationToken) {
|
||||
prompt("The modification token for your paste is:", data.modificationToken);
|
||||
prompt("The modification token for your pastes is:", data.modificationToken);
|
||||
}
|
||||
|
||||
// Redirect the user to his newly created paste
|
||||
// Redirect the user to his newly created pastes
|
||||
location.replace(location.protocol + "//" + location.host + "/" + data.id + (key ? "#" + key : ""));
|
||||
});
|
||||
});
|
||||
@ -333,10 +333,10 @@ function setupButtonFunctionality() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to delete the paste
|
||||
// Try to delete the pastes
|
||||
const response = await API.deletePaste(PASTE_ID, modificationToken);
|
||||
if (!response.ok) {
|
||||
Notifications.error("Error while deleting paste: <b>" + await response.text() + "</b>");
|
||||
Notifications.error("Error while deleting pastes: <b>" + await response.text() + "</b>");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -369,17 +369,17 @@ function setupButtonFunctionality() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-encrypt the paste data if needed
|
||||
// Re-encrypt the pastes data if needed
|
||||
let value = INPUT_ELEMENT.value;
|
||||
if (ENCRYPTION_KEY && ENCRYPTION_IV) {
|
||||
const encrypted = await Encryption.encrypt(await Encryption.encryptionDataFromHex(ENCRYPTION_KEY, ENCRYPTION_IV), value);
|
||||
value = encrypted.result;
|
||||
}
|
||||
|
||||
// Try to edit the paste
|
||||
// Try to edit the pastes
|
||||
const response = await API.editPaste(PASTE_ID, modificationToken, value);
|
||||
if (!response.ok) {
|
||||
Notifications.error("Error while editing paste: <b>" + await response.text() + "</b>");
|
||||
Notifications.error("Error while editing pastes: <b>" + await response.text() + "</b>");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -387,13 +387,13 @@ function setupButtonFunctionality() {
|
||||
CODE = INPUT_ELEMENT.value;
|
||||
updateCode();
|
||||
toggleEditMode();
|
||||
Notifications.success("Successfully edited paste.");
|
||||
Notifications.success("Successfully edited pastes.");
|
||||
});
|
||||
|
||||
BUTTON_TOGGLE_ENCRYPTION_ELEMENT.addEventListener("click", () => {
|
||||
const active = BUTTON_TOGGLE_ENCRYPTION_ELEMENT.classList.toggle("active");
|
||||
localStorage.setItem("encryption", active);
|
||||
Notifications.success((active ? "Enabled" : "Disabled") + " automatic paste encryption.");
|
||||
Notifications.success((active ? "Enabled" : "Disabled") + " automatic pastes encryption.");
|
||||
});
|
||||
|
||||
BUTTON_REPORT_ELEMENT.addEventListener("click", async () => {
|
||||
@ -403,17 +403,17 @@ function setupButtonFunctionality() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to report the paste
|
||||
// Try to report the pastes
|
||||
const response = await API.reportPaste(PASTE_ID, reason);
|
||||
if (!response.ok) {
|
||||
Notifications.error("Error while reporting paste: <b>" + await response.text() + "</b>");
|
||||
Notifications.error("Error while reporting pastes: <b>" + await response.text() + "</b>");
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the response message
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
Notifications.error("Error while reporting paste: <b>" + data.message + "</b>");
|
||||
Notifications.error("Error while reporting pastes: <b>" + data.message + "</b>");
|
||||
return;
|
||||
}
|
||||
Notifications.success(data.message);
|
||||
|
Loading…
Reference in New Issue
Block a user