mirror of https://github.com/lus/pasty.git
Compare commits
9 Commits
e93b292daf
...
6260f20fc4
Author | SHA1 | Date |
---|---|---|
Lukas Schulte Pelkum | 6260f20fc4 | |
Lukas Schulte Pelkum | dc16506932 | |
Lukas Schulte Pelkum | a24be8b2ff | |
Lukas Schulte Pelkum | 4ce806945d | |
Lukas Schulte Pelkum | a53bd39dbd | |
Lukas Schulte Pelkum | 941da057ae | |
Lukas Schulte Pelkum | a8077a54f9 | |
Lukas Schulte Pelkum | 8b1a4d23b7 | |
Lukas Schulte Pelkum | 3575c02c1e |
|
@ -1,4 +1,3 @@
|
|||
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/jetbrains+all,go
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=jetbrains+all,go
|
||||
|
||||
|
@ -114,6 +113,4 @@ modules.xml
|
|||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/jetbrains+all,go
|
||||
|
||||
web/*.gz
|
||||
data/
|
||||
.env
|
||||
.env
|
||||
|
|
|
@ -22,6 +22,6 @@ RUN go build \
|
|||
FROM gcr.io/distroless/base:latest
|
||||
WORKDIR /root
|
||||
COPY --from=build /app/pasty .
|
||||
COPY web ./web/
|
||||
COPY internal/web/web ./web/
|
||||
EXPOSE 8080
|
||||
CMD ["./pasty"]
|
|
@ -1,52 +1,106 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/lus/pasty/internal/config"
|
||||
"github.com/lus/pasty/internal/meta"
|
||||
"github.com/lus/pasty/internal/storage"
|
||||
"github.com/lus/pasty/internal/storage/postgres"
|
||||
"github.com/lus/pasty/internal/web"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"net/http"
|
||||
"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)
|
||||
}
|
||||
}()
|
||||
// Start the web server
|
||||
log.Info().Str("address", cfg.WebAddress).Msg("Starting the web server...")
|
||||
var adminTokens []string
|
||||
if cfg.ModificationTokenMaster != "" {
|
||||
adminTokens = []string{cfg.ModificationTokenMaster}
|
||||
}
|
||||
webServer := &web.Server{
|
||||
Address: cfg.WebAddress,
|
||||
Storage: driver,
|
||||
HastebinSupport: cfg.HastebinSupport,
|
||||
PasteIDLength: cfg.IDLength,
|
||||
PasteIDCharset: cfg.IDCharacters,
|
||||
PasteLengthCap: cfg.LengthCap,
|
||||
ModificationTokensEnabled: cfg.ModificationTokens,
|
||||
ModificationTokenLength: cfg.ModificationTokenLength,
|
||||
ModificationTokenCharset: cfg.ModificationTokenCharacters,
|
||||
AdminTokens: adminTokens,
|
||||
}
|
||||
go func() {
|
||||
if err := webServer.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal().Err(err).Msg("Could not start the web server.")
|
||||
}
|
||||
}()
|
||||
defer func() {
|
||||
log.Info().Msg("Shutting down the web server...")
|
||||
if err := webServer.Shutdown(context.Background()); err != nil {
|
||||
log.Err(err).Msg("Could not shut down the web server.")
|
||||
}
|
||||
}()
|
||||
|
||||
// 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,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
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,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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package meta
|
||||
|
||||
import "strings"
|
||||
|
||||
const devEnvironmentName = "dev"
|
||||
|
||||
var (
|
||||
Environment = devEnvironmentName
|
||||
Version = "dev"
|
||||
)
|
||||
|
||||
func IsProdEnvironment() bool {
|
||||
return strings.ToLower(Environment) != devEnvironmentName
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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,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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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/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
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
|
@ -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/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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -215,7 +215,7 @@ function toggleEditMode() {
|
|||
function setupKeybinds() {
|
||||
window.addEventListener("keydown", (event) => {
|
||||
// All keybinds in the default button set include the CTRL key
|
||||
if ((EDIT_MODE && !event.ctrlKey && event.code !== "Escape") || (!EDIT_MODE && !event.ctrlKey)) {
|
||||
if ((EDIT_MODE && !event.ctrlKey && !event.metaKey && event.code !== "Escape") || (!EDIT_MODE && !event.ctrlKey && !event.metaKey)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed frontend/*
|
||||
var frontend embed.FS
|
||||
|
||||
func frontendHandler(notFoundHandler http.HandlerFunc) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
path := strings.TrimSpace(strings.TrimSuffix(request.URL.Path, "/"))
|
||||
|
||||
isFirstLevel := strings.Count(path, "/") <= 1
|
||||
|
||||
file, err := frontend.Open(filepath.Join("frontend", path))
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
if isFirstLevel {
|
||||
serveIndexFile(writer, request)
|
||||
} else {
|
||||
notFoundHandler(writer, request)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeErr(writer, err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
writeErr(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
if fileInfo.IsDir() {
|
||||
if isFirstLevel {
|
||||
serveIndexFile(writer, request)
|
||||
} else {
|
||||
notFoundHandler(writer, request)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
writeErr(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
writer.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(fileInfo.Name())))
|
||||
writer.Header().Set("Content-Length", strconv.Itoa(len(content)))
|
||||
_, _ = writer.Write(content)
|
||||
}
|
||||
}
|
||||
|
||||
func serveIndexFile(writer http.ResponseWriter, _ *http.Request) {
|
||||
indexFile, err := frontend.ReadFile("frontend/index.html")
|
||||
if err != nil {
|
||||
writeErr(writer, err)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", "text/html")
|
||||
writer.Header().Set("Content-Length", strconv.Itoa(len(indexFile)))
|
||||
_, _ = writer.Write(indexFile)
|
||||
}
|
|
@ -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{}) {
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/lus/pasty/internal/meta"
|
||||
"github.com/lus/pasty/internal/pastes"
|
||||
"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
|
||||
|
||||
httpServer *http.Server
|
||||
}
|
||||
|
||||
func (server *Server) Start() error {
|
||||
router := chi.NewRouter()
|
||||
|
||||
// Register the web frontend handler
|
||||
router.Get("/*", frontendHandler(router.NotFoundHandler()))
|
||||
|
||||
// Register the raw paste handler
|
||||
router.With(server.v2MiddlewareInjectPaste).Get("/{paste_id}/raw", 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
|
||||
}
|
||||
_, _ = writer.Write([]byte(paste.Content))
|
||||
})
|
||||
|
||||
// Register the paste API endpoints
|
||||
router.Get("/api/*", router.NotFoundHandler())
|
||||
router.With(server.v2MiddlewareInjectPaste).Get("/api/v2/pastes/{paste_id}", server.v2EndpointGetPaste)
|
||||
router.Post("/api/v2/pastes", server.v2EndpointCreatePaste)
|
||||
router.With(server.v2MiddlewareInjectPaste, server.v2MiddlewareAuthorize).Patch("/api/v2/pastes/{paste_id}", server.v2EndpointModifyPaste)
|
||||
router.With(server.v2MiddlewareInjectPaste, server.v2MiddlewareAuthorize).Delete("/api/v2/pastes/{paste_id}", server.v2EndpointDeletePaste)
|
||||
router.Get("/api/v2/info", func(writer http.ResponseWriter, request *http.Request) {
|
||||
writeJSONOrErr(writer, http.StatusOK, map[string]any{
|
||||
"version": meta.Version,
|
||||
"modificationTokens": server.ModificationTokensEnabled,
|
||||
"reports": false, // TODO: Return report state
|
||||
"pasteLifetime": -1, // TODO: Return paste lifetime
|
||||
})
|
||||
})
|
||||
|
||||
// Start the HTTP server
|
||||
server.httpServer = &http.Server{
|
||||
Addr: server.Address,
|
||||
Handler: router,
|
||||
}
|
||||
return server.httpServer.ListenAndServe()
|
||||
}
|
||||
|
||||
func (server *Server) Shutdown(ctx context.Context) error {
|
||||
if err := server.httpServer.Shutdown(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
server.httpServer = nil
|
||||
return nil
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
Loading…
Reference in New Issue