Compare commits

...

4 Commits

Author SHA1 Message Date
Lukas Schulte Pelkum 941da057ae
reimplement v2 API controller 2023-06-07 19:51:54 +02:00
Lukas Schulte Pelkum a8077a54f9
update dependencies 2023-06-07 18:27:23 +02:00
Lukas Schulte Pelkum 8b1a4d23b7
restructure storage driver 2023-06-07 18:25:28 +02:00
Lukas Schulte Pelkum 3575c02c1e
restructure startup & config logic 2023-06-07 17:46:19 +02:00
37 changed files with 784 additions and 2438 deletions

View File

@ -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
}

View File

@ -1,81 +0,0 @@
package main
import (
"log"
"os"
"github.com/lus/pasty/internal/config"
"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(storage.Type(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(storage.Type(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
View File

@ -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
)

1034
go.sum

File diff suppressed because it is too large Load Diff

View File

@ -1,123 +1,50 @@
package config
import (
"strings"
"github.com/joho/godotenv"
"github.com/kelseyhightower/envconfig"
"time"
"github.com/lus/pasty/internal/env"
)
// Config represents the general application configuration structure
type Config struct {
WebAddress string
StorageType string
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: 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", true),
ModificationTokenMaster: env.MustString("MODIFICATION_TOKEN_MASTER", ""),
ModificationTokenLength: env.MustInt("MODIFICATION_TOKEN_LENGTH", 12),
ModificationTokenCharacters: env.MustString("MODIFICATION_TOKEN_CHARACTERS", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"),
RateLimit: env.MustString("RATE_LIMIT", "30-M"),
LengthCap: env.MustInt("LENGTH_CAP", 50000),
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
View File

@ -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
View File

@ -0,0 +1,13 @@
package meta
import "strings"
const devEnvironmentName = "dev"
var (
Environment = devEnvironmentName
)
func IsProdEnvironment() bool {
return strings.ToLower(Environment) != devEnvironmentName
}

View File

@ -1,30 +0,0 @@
package paste
import (
"github.com/alexedwards/argon2id"
)
// Paste represents a paste
type Paste struct {
ID string `json:"id"`
Content string `json:"content"`
ModificationToken string `json:"modificationToken,omitempty"`
Created int64 `json:"created"`
Metadata map[string]interface{} `json:"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 {
match, err := argon2id.ComparePasswordAndHash(modificationToken, paste.ModificationToken)
return err == nil && match
}

View 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
View 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
}

View 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
View 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)
}

View File

@ -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
}

View 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
}

View File

@ -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"
)

View File

@ -1,60 +1,12 @@
package storage
import (
"fmt"
"strings"
"github.com/lus/pasty/internal/config"
"github.com/lus/pasty/internal/paste"
"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) (*paste.Paste, error)
Save(paste *paste.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 string) (Driver, error) {
switch strings.TrimSpace(strings.ToLower(storageType)) {
case "file":
return new(file.FileDriver), nil
case "postgres":
return new(postgres.PostgresDriver), nil
case "mongodb":
return new(mongodb.MongoDBDriver), nil
case "s3":
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
}

View File

@ -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/paste"
)
// 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(_ 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) (*paste.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(paste.Paste)
err = json.Unmarshal(data, &paste)
if err != nil {
return nil, err
}
return paste, nil
}
// Save saves a paste
func (driver *FileDriver) Save(paste *paste.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
}

View File

@ -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
}
}
}

View File

@ -1,171 +0,0 @@
package mongodb
import (
"context"
"time"
"github.com/lus/pasty/internal/config"
"github.com/lus/pasty/internal/paste"
"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 []paste.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) (*paste.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(paste.Paste)
err = result.Decode(paste)
if err != nil {
return nil, err
}
return paste, nil
}
// Save saves a paste
func (driver *MongoDBDriver) Save(paste *paste.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
}

View 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
}

View 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
}

View File

@ -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/paste"
)
//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) (*paste.Paste, error) {
query := "SELECT * FROM pastes WHERE id = $1"
row := driver.pool.QueryRow(context.Background(), query, id)
paste := new(paste.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 *paste.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
}

View File

@ -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/paste"
"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) (*paste.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(paste.Paste)
err = json.Unmarshal(data, &paste)
if err != nil {
return nil, err
}
return paste, nil
}
// Save saves a paste
func (driver *S3Driver) Save(paste *paste.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
}

View File

@ -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)
}

View File

@ -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{}) {
}

View 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)
}
}

View File

@ -1,137 +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"
v2 "github.com/lus/pasty/internal/web/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")
{
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 {
// TODO: Reimplement hastebin support
}
// 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)
}
}

49
internal/web/server.go Normal file
View 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)
}

View File

@ -1,273 +0,0 @@
package v2
import (
"encoding/json"
"strings"
"time"
"github.com/fasthttp/router"
"github.com/lus/pasty/internal/config"
"github.com/lus/pasty/internal/paste"
"github.com/lus/pasty/internal/report"
"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").(*paste.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").(*paste.Paste)
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 := &paste.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").(*paste.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").(*paste.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").(*paste.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)
}

View 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)
}

View 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)
}
}

View 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)
}

View 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)
}
}

View 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)
})
}

View 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)
})
}

View File

@ -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");

View File

@ -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);