diff --git a/README.md b/README.md index 8322e9f..217a315 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Pasty will be available at http://localhost:8080. | `PASTY_HASTEBIN_SUPPORT` | `false` | `bool` | Defines whether or not the `POST /documents` endpoint should be enabled, as known from the hastebin servers | | `PASTY_ID_LENGTH` | `6` | `number` | Defines the length of the ID of a paste | | `PASTY_MODIFICATION_TOKENS` | `true` | `bool` | Defines whether or not modification tokens should be generated | -| `PASTY_MODIFICATION_TOKEN_MASTER` | `` | `string` | Defines the master modification token which is authorized to modify every paste (even if modification tokens are disabled) | +| `PASTY_MODIFICATION_TOKEN_MASTER` | `` | `string` | Defines the master modification token which is authorized to modify every paste (even if modification tokens are disabled) | | `PASTY_MODIFICATION_TOKEN_LENGTH` | `12` | `number` | Defines the length of the modification token of a paste | | `PASTY_RATE_LIMIT` | `30-M` | `string` | Defines the rate limit of the API (see https://github.com/ulule/limiter#usage) | | `PASTY_LENGTH_CAP` | `50000` | `number` | Defines the maximum amount of characters a paste is allowed to contain (a value `<= 0` means no limit) | diff --git a/internal/web/controllers/v2/pastes.go b/internal/web/controllers/v2/pastes.go new file mode 100644 index 0000000..70724cc --- /dev/null +++ b/internal/web/controllers/v2/pastes.go @@ -0,0 +1,174 @@ +package v2 + +import ( + "encoding/json" + "strings" + "time" + + "github.com/fasthttp/router" + "github.com/lus/pasty/internal/config" + "github.com/lus/pasty/internal/shared" + "github.com/lus/pasty/internal/storage" + "github.com/lus/pasty/internal/utils" + limitFasthttp "github.com/ulule/limiter/v3/drivers/middleware/fasthttp" + "github.com/valyala/fasthttp" +) + +// InitializePastesController initializes the '/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.DELETE("/{id}", rateLimiterMiddleware.Handle(middlewareInjectPaste(middlewareValidateModificationToken(endpointDeletePaste)))) +} + +// 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 + } + + ctx.SetUserValue("_paste", paste) + + next(ctx) + } +} + +// middlewareValidateModificationToken extracts and validates a given modification token for an injected paste +func middlewareValidateModificationToken(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + paste := ctx.UserValue("_paste").(*shared.Paste) + + authHeaderSplit := strings.SplitN(string(ctx.Request.Header.Peek("Authorization")), " ", 2) + if len(authHeaderSplit) < 2 || authHeaderSplit[0] != "Bearer" { + ctx.SetStatusCode(fasthttp.StatusUnauthorized) + ctx.SetBodyString("unauthorized") + return + } + + modificationToken := authHeaderSplit[1] + if config.Current.ModificationTokenMaster != "" && modificationToken == config.Current.ModificationTokenMaster { + next(ctx) + return + } + valid := paste.CheckModificationToken(modificationToken) + if !valid { + ctx.SetStatusCode(fasthttp.StatusUnauthorized) + ctx.SetBodyString("unauthorized") + return + } + + next(ctx) + } +} + +// endpointGetPaste handles the 'GET /v2/pastes/{id}' endpoint +func endpointGetPaste(ctx *fasthttp.RequestCtx) { + paste := ctx.UserValue("_paste").(*shared.Paste) + paste.DeletionToken = "" + paste.ModificationToken = "" + + jsonData, err := json.Marshal(paste) + if err != nil { + ctx.SetStatusCode(fasthttp.StatusInternalServerError) + ctx.SetBodyString(err.Error()) + return + } + ctx.SetBody(jsonData) +} + +type endpointCreatePastePayload struct { + Content string `json:"content"` +} + +// 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 + paste := &shared.Paste{ + ID: id, + Content: payload.Content, + Created: time.Now().Unix(), + AutoDelete: config.Current.AutoDelete.Enabled, + } + + // Create a new modification token if enabled + modificationToken := "" + if config.Current.ModificationTokens { + modificationToken = utils.RandomString(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) +} + +// endpointDeletePaste handles the 'DELETE /v2/pastes/{id}' endpoint +func endpointDeletePaste(ctx *fasthttp.RequestCtx) { + paste := ctx.UserValue("_paste").(*shared.Paste) + if err := storage.Current.Delete(paste.ID); err != nil { + ctx.SetStatusCode(fasthttp.StatusInternalServerError) + ctx.SetBodyString(err.Error()) + return + } +} diff --git a/internal/web/web.go b/internal/web/web.go index 9394245..02d0146 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -10,6 +10,7 @@ import ( "github.com/lus/pasty/internal/static" "github.com/lus/pasty/internal/storage" v1 "github.com/lus/pasty/internal/web/controllers/v1" + v2 "github.com/lus/pasty/internal/web/controllers/v2" "github.com/ulule/limiter/v3" limitFasthttp "github.com/ulule/limiter/v3/drivers/middleware/fasthttp" "github.com/ulule/limiter/v3/drivers/store/memory" @@ -67,6 +68,18 @@ func Serve() error { }) v1.InitializePastesController(v1Route.Group("/pastes"), rateLimiterMiddleware) } + + v2Route := apiRoute.Group("/v2") + { + v2Route.GET("/info", func(ctx *fasthttp.RequestCtx) { + jsonData, _ := json.Marshal(map[string]interface{}{ + "version": static.Version, + "modificationTokens": config.Current.ModificationTokens, + }) + ctx.SetBody(jsonData) + }) + v2.InitializePastesController(v2Route.Group("/pastes"), rateLimiterMiddleware) + } } // Route the hastebin documents route if hastebin support is enabled