Implement API v2 (#13)

* Add documentation notice

* Rename the deletion token to modification token

* Implement API basics

* Implement paste modification endpoint (#10)

* Implement paste metadata

* Implement report webhook support

* Document API

* Document paste entity

* Update syntax highlighting types (js -> jsonc)

* Update migrator
This commit is contained in:
Lukas Schulte Pelkum 2021-07-22 22:26:21 +02:00 committed by GitHub
parent 4c392b4b52
commit 99504e0bba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 723 additions and 71 deletions

181
API.md Normal file
View File

@ -0,0 +1,181 @@
# API
The REST API provided by pasty is the most important entrypoint when it comes to interacting with it. Basically everything, including the pasty frontend, is built on top of it.
To make things easier for other developers who decide to develop something in connection to pasty, everything important about it is documented here.
## Authentication/Authorization
Not everyone should be able to view, edit or delete all pastes. However, admins should be.
In order to achieve that, an effective auth flow is required.
There are two ways of authenticating:
### 1.) Paste-pecific
The `Authorization` header is set to `Bearer <modification_token>`, where `<modification_token>` is replaced with the corresponding paste-specific **modification token**.
This authentication is only valid for the requested paste.
### 2.) Admin tokens
The `Authorization` header is set to `Bearer <admin_token>`, where `<admin_token>` is replaced with the configured **administration token**.
This authentication is valid for all endpoints, regardless of the requested paste.
### Notation
In the folllowing, all endpoints that require an **admin token** are annotated with `[ADMIN]`.
All endpoints that are accessible through the **admin and modification token** are annotated with `[PASTE_SPECIFIC]`.
All endpoints that are accessible to everyone are annotated with `[UNSECURED]`.
## The paste entity
The central paste entity has the following fields:
* `id` (string)
* `content` (string)
* `modificationToken` (string)
* The token used to authenticate with paste-specific secured endpoints; stored hashed and only returned on initial paste creation
* `created` (int64; UNIX timestamp)
* `autoDelete` (boolean)
* The AutoDelete feature works on a paste-specific basis (even if you turn it off, pastes created while it was on will still be automatically deleted)
* `metadata` (key-value store)
* Different frontends may store simple key-value metadata pairs on pastes to enable specific functionality (for example clientside encryption)
## Endpoints
### [UNSECURED] Retrieve application information
```http
GET /api/v2/info
```
**Request:**
none
**Response:**
```json
{
"modificationTokens": true,
"reports": true,
"version": "dev"
}
```
---
### [UNSECURED] Retrieve a paste
```http
GET /api/v2/pastes/{paste_id}
```
**Request:**
none
**Response:**
```json
{
"id": "paste_id",
"content": "paste_content",
"created": 0000000000,
"autoDelete": false,
"metadata": {},
}
```
---
### [UNSECURED] Create a paste
```http
POST /api/v2/pastes
```
**Request:**
```jsonc
{
"content": "paste_content", // Required
"metadata": {} // Optional
}
```
**Response:**
```json
{
"id": "paste_id",
"content": "paste_content",
"modificationToken": "raw_modification_token",
"created": 0000000000,
"autoDelete": false,
"metadata": {},
}
```
---
### [PASTE_SPECIFIC] Update a paste
```http
PATCH /api/v2/pastes/{paste_id}
```
**Request:**
```jsonc
{
"content": "new_paste_content", // Optional
"metadata": {} // Optional
}
```
**Response:**
```json
{
"id": "paste_id",
"content": "new_paste_content",
"created": 0000000000,
"autoDelete": false,
"metadata": {},
}
```
**Notes:**
* Changes in the `metadata` field only affect the corresponding field and don't override the whole key-value store (`{"metadata": {"foo": "bar"}}` will effectively add or replace the `foo` key but won't affect other keys).
* To remove a key from the key-value store simply set it to `null`.
---
### [PASTE_SPECIFIC] Delete a paste
```http
DELETE /api/v2/pastes/{paste_id}
```
**Request:**
none
**Response:**
none
---
### [UNSECURED] Report a paste
```http
POST /api/v2/pastes/{paste_id}/report
```
**Request:**
```json
{
"reason": "reason"
}
```
**Response:**
```jsonc
{
"message": "message" // An optional message to display to the reporting user
}
```
**Notes:**
* The endpoint is only available if the report system is enabled. Otherwise it will return a `404 Not Found` error.

View File

@ -1,6 +1,23 @@
# pasty
Pasty is a fast and lightweight code pasting server
## !!! Important deprecation notices !!!
> This version of pasty uses a new field name for the so far called `deletionToken`: `modificationToken`.
> Instances using **PostgreSQL** are **not affected** as a corresponding SQL migration will run before the first startup.
> If you however use **another storage driver** you may have to **update the entries** by hand or using a simple query, depending on your driver as I don't plan to ship migrations for every single storage driver.
> It may be important to know that the **data migrator has been upgraded** too. This may serve as a **convenient workaround** (export data (field will be renamed) and import data with changed field names again).
>
> The old `deletionToken` field will be processed corresponding to these changes but I strongly recommend updating old pastes if possible.
> Additionally, I changed the three `DELETION_TOKEN*`environment variables to their corresponding `MODIFICATION_TOKEN*` ones:
> - `DELETION_TOKENS` -> `MODIFICATION_TOKENS`
> - `DELETION_TOKEN_MASTER` -> `MODIFICATION_TOKEN_MASTER`
> - `DELETION_TOKEN_LENGTH` -> `MODIFICATION_TOKEN_LENGTH`
>
> Again, **the old ones will still work** because I do not want to jumble your configurations. However, **please consider updating** them to stay future-proof ^^.
## Support
As pasty is an open source project on GitHub you can open an [issue](https://github.com/lus/pasty/issues) whenever you encounter a problem or feature request.
@ -55,17 +72,17 @@ Pasty will be available at http://localhost:8080.
---
## General environment variables
| Environment Variable | Default Value | Type | Description |
|-------------------------------|---------------|----------|--------------------------------------------------------------------------------------------------------------------|
| `PASTY_WEB_ADDRESS` | `:8080` | `string` | Defines the address the web server listens to |
| `PASTY_STORAGE_TYPE` | `file` | `string` | Defines the storage type the pastes are saved to |
| `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_DELETION_TOKENS` | `true` | `bool` | Defines whether or not deletion tokens should be generated |
| `PASTY_DELETION_TOKEN_MASTER` | `<empty>` | `string` | Defines the master deletion token which is authorized to delete every paste (even if deletion tokens are disabled) |
| `PASTY_DELETION_TOKEN_LENGTH` | `12` | `number` | Defines the length of the deletion 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) |
| Environment Variable | Default Value | Type | Description |
|-----------------------------------|---------------|----------|----------------------------------------------------------------------------------------------------------------------------|
| `PASTY_WEB_ADDRESS` | `:8080` | `string` | Defines the address the web server listens to |
| `PASTY_STORAGE_TYPE` | `file` | `string` | Defines the storage type the pastes are saved to |
| `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` | `<empty>` | `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) |
## AutoDelete
Pasty provides an intuitive system to automatically delete pastes after a specific amount of time. You can configure it with the following variables:
@ -75,6 +92,16 @@ Pasty provides an intuitive system to automatically delete pastes after a specif
| `PASTY_AUTODELETE_LIFETIME` | `720h` | `string` | Defines the duration a paste should live until it gets deleted |
| `PASTY_AUTODELETE_TASK_INTERVAL` | `5m` | `string` | Defines the interval in which the AutoDelete task should clean up the database |
## Reports
Pasty aims at being lightweight by default. This is why no fully-featured admin interface with an overview over all pastes and reports is included.
However, pasty does include a way of abstract reports to allow frontends work with this information.
If enabled, pasty makes a standardized request to the configured webhook URL if a paste is reported.
| Environment Variable | Default Value | Type | Description |
|------------------------------|---------------|----------|-----------------------------------------------------------------------------------------------------|
| `PASTY_REPORTS` | `false` | `bool` | Defines whether or not the report system should be enabled |
| `PASTY_REPORT_WEBHOOK` | `<empty>` | `string` | Defines the webhook URL that is called whenever a paste is reported |
| `PASTY_REPORT_WEBHOOK_TOKEN` | `<empty>` | `string` | Defines the token that is sent in the `Authorization` header on every request to the report webhook |
## Storage types
Pasty supports multiple storage types, defined using the `PASTY_STORAGE_TYPE` environment variable (use the value behind the corresponding title in this README).
Every single one of them has its own configuration variables:

View File

@ -56,6 +56,20 @@ func main() {
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 {

View File

@ -10,20 +10,21 @@ import (
// Config represents the general application configuration structure
type Config struct {
WebAddress string
StorageType shared.StorageType
HastebinSupport bool
IDLength int
DeletionTokens bool
DeletionTokenMaster string
DeletionTokenLength int
RateLimit string
LengthCap int
AutoDelete *AutoDeleteConfig
File *FileConfig
Postgres *PostgresConfig
MongoDB *MongoDBConfig
S3 *S3Config
WebAddress string
StorageType shared.StorageType
HastebinSupport bool
IDLength int
ModificationTokens bool
ModificationTokenMaster string
ModificationTokenLength int
RateLimit string
LengthCap int
AutoDelete *AutoDeleteConfig
Reports *ReportConfig
File *FileConfig
Postgres *PostgresConfig
MongoDB *MongoDBConfig
S3 *S3Config
}
// AutoDeleteConfig represents the configuration specific for the AutoDelete behaviour
@ -61,6 +62,13 @@ type S3Config struct {
Bucket string
}
// ReportConfig represents the configuration specific for the report system
type ReportConfig struct {
Reports bool
ReportWebhook string
ReportWebhookToken string
}
// Current holds the currently loaded config
var Current *Config
@ -69,20 +77,25 @@ func Load() {
env.Load()
Current = &Config{
WebAddress: env.MustString("WEB_ADDRESS", ":8080"),
StorageType: shared.StorageType(strings.ToLower(env.MustString("STORAGE_TYPE", "file"))),
HastebinSupport: env.MustBool("HASTEBIN_SUPPORT", false),
IDLength: env.MustInt("ID_LENGTH", 6),
DeletionTokens: env.MustBool("DELETION_TOKENS", true),
DeletionTokenMaster: env.MustString("DELETION_TOKEN_MASTER", ""),
DeletionTokenLength: env.MustInt("DELETION_TOKEN_LENGTH", 12),
RateLimit: env.MustString("RATE_LIMIT", "30-M"),
LengthCap: env.MustInt("LENGTH_CAP", 50_000),
WebAddress: env.MustString("WEB_ADDRESS", ":8080"),
StorageType: shared.StorageType(strings.ToLower(env.MustString("STORAGE_TYPE", "file"))),
HastebinSupport: env.MustBool("HASTEBIN_SUPPORT", false),
IDLength: env.MustInt("ID_LENGTH", 6),
ModificationTokens: env.MustBool("MODIFICATION_TOKENS", env.MustBool("DELETION_TOKENS", true)), // ---
ModificationTokenMaster: env.MustString("MODIFICATION_TOKEN_MASTER", env.MustString("DELETION_TOKEN_MASTER", "")), // - We don't want to destroy peoples old configuration
ModificationTokenLength: env.MustInt("MODIFICATION_TOKEN_LENGTH", env.MustInt("DELETION_TOKEN_LENGTH", 12)), // ---
RateLimit: env.MustString("RATE_LIMIT", "30-M"),
LengthCap: env.MustInt("LENGTH_CAP", 50_000),
AutoDelete: &AutoDeleteConfig{
Enabled: env.MustBool("AUTODELETE", false),
Lifetime: env.MustDuration("AUTODELETE_LIFETIME", 720*time.Hour),
TaskInterval: env.MustDuration("AUTODELETE_TASK_INTERVAL", 5*time.Minute),
},
Reports: &ReportConfig{
Reports: env.MustBool("REPORTS", false),
ReportWebhook: env.MustString("REPORT_WEBHOOK", ""),
ReportWebhookToken: env.MustString("REPORT_WEBHOOK_TOKEN", ""),
},
File: &FileConfig{
Path: env.MustString("STORAGE_FILE_PATH", "./data"),
},

57
internal/report/report.go Normal file
View File

@ -0,0 +1,57 @@
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"`
Timestamp int64 `json:"timestamp"`
}
// ReportResponse represents a report response received from the report webhook
type ReportResponse struct {
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

@ -1,30 +1,43 @@
package shared
import (
"log"
"github.com/alexedwards/argon2id"
)
// Paste represents a saved paste
type Paste struct {
ID string `json:"id" bson:"_id"`
Content string `json:"content" bson:"content"`
DeletionToken string `json:"deletionToken,omitempty" bson:"deletionToken"`
Created int64 `json:"created" bson:"created"`
AutoDelete bool `json:"autoDelete" bson:"autoDelete"`
ID string `json:"id" bson:"_id"`
Content string `json:"content" bson:"content"`
DeletionToken string `json:"deletionToken,omitempty" bson:"deletionToken"` // Required for legacy paste storage support
ModificationToken string `json:"modificationToken,omitempty" bson:"modificationToken"`
Created int64 `json:"created" bson:"created"`
AutoDelete bool `json:"autoDelete" bson:"autoDelete"`
Metadata map[string]interface{} `json:"metadata" bson:"metadata"`
}
// HashDeletionToken hashes the current deletion token of a paste
func (paste *Paste) HashDeletionToken() error {
hash, err := argon2id.CreateHash(paste.DeletionToken, argon2id.DefaultParams)
// 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.DeletionToken = hash
paste.ModificationToken = hash
return nil
}
// CheckDeletionToken checks whether or not the given deletion token is correct
func (paste *Paste) CheckDeletionToken(deletionToken string) bool {
match, err := argon2id.ComparePasswordAndHash(deletionToken, paste.DeletionToken)
// CheckModificationToken checks whether or not the given modification token is correct
func (paste *Paste) CheckModificationToken(modificationToken string) bool {
// The modification token may be stored in the deletion token field in old pastes
usedToken := paste.ModificationToken
if usedToken == "" {
usedToken = paste.DeletionToken
if usedToken != "" {
log.Println("WARNING: You seem to have pastes with the old 'deletionToken' field stored in your storage driver. Though this does not cause any issues right now, it may in the future. Consider some kind of migration.")
}
}
match, err := argon2id.ComparePasswordAndHash(modificationToken, usedToken)
return err == nil && match
}

View File

@ -117,8 +117,9 @@ func (driver *MongoDBDriver) Save(paste *shared.Paste) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Insert the paste object
_, err := collection.InsertOne(ctx, paste)
// Upsert the paste object
filter := bson.M{"_id": paste.ID}
_, err := collection.UpdateOne(ctx, filter, paste, options.Update().SetUpsert(true))
return err
}

View File

@ -0,0 +1,5 @@
begin;
alter table if exists "pastes" rename column "modificationToken" to "deletionToken";
commit;

View File

@ -0,0 +1,5 @@
begin;
alter table if exists "pastes" rename column "deletionToken" to "modificationToken";
commit;

View File

@ -0,0 +1,5 @@
begin;
alter table if exists "pastes" drop column "metadata";
commit;

View File

@ -0,0 +1,5 @@
begin;
alter table if exists "pastes" add column "metadata" jsonb not null;
commit;

View File

@ -82,7 +82,7 @@ func (driver *PostgresDriver) Get(id string) (*shared.Paste, error) {
row := driver.pool.QueryRow(context.Background(), query, id)
paste := new(shared.Paste)
if err := row.Scan(&paste.ID, &paste.Content, &paste.DeletionToken, &paste.Created, &paste.AutoDelete); err != nil {
if err := row.Scan(&paste.ID, &paste.Content, &paste.ModificationToken, &paste.Created, &paste.AutoDelete, &paste.Metadata); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
@ -93,9 +93,18 @@ func (driver *PostgresDriver) Get(id string) (*shared.Paste, error) {
// Save saves a paste
func (driver *PostgresDriver) Save(paste *shared.Paste) error {
query := "INSERT INTO pastes VALUES ($1, $2, $3, $4, $5)"
query := `
INSERT INTO pastes (id, content, modificationToken, created, autoDelete)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE
SET content = excluded.token,
modificationToken = excluded.modificationToken,
created = excluded.created,
autoDelete = excluded.autoDelete,
metadata = excluded.metadata
`
_, err := driver.pool.Exec(context.Background(), query, paste.ID, paste.Content, paste.DeletionToken, paste.Created, paste.AutoDelete)
_, err := driver.pool.Exec(context.Background(), query, paste.ID, paste.Content, paste.ModificationToken, paste.Created, paste.AutoDelete, paste.Metadata)
return err
}

View File

@ -50,11 +50,11 @@ func HastebinSupportHandler(ctx *fasthttp.RequestCtx) {
AutoDelete: config.Current.AutoDelete.Enabled,
}
// Set a deletion token
if config.Current.DeletionTokens {
paste.DeletionToken = utils.RandomString(config.Current.DeletionTokenLength)
// Set a modification token
if config.Current.ModificationTokens {
paste.ModificationToken = utils.RandomString(config.Current.ModificationTokenLength)
err = paste.HashDeletionToken()
err = paste.HashModificationToken()
if err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
ctx.SetBodyString(err.Error())

View File

@ -0,0 +1,26 @@
package v1
import "github.com/lus/pasty/internal/shared"
type legacyPaste struct {
ID string `json:"id"`
Content string `json:"content"`
DeletionToken string `json:"deletionToken,omitempty"`
Created int64 `json:"created"`
AutoDelete bool `json:"autoDelete"`
}
func legacyFromModern(paste *shared.Paste) *legacyPaste {
deletionToken := paste.ModificationToken
if deletionToken == "" {
deletionToken = paste.DeletionToken
}
return &legacyPaste{
ID: paste.ID,
Content: paste.Content,
DeletionToken: deletionToken,
Created: paste.Created,
AutoDelete: paste.AutoDelete,
}
}

View File

@ -37,10 +37,11 @@ func v1GetPaste(ctx *fasthttp.RequestCtx) {
ctx.SetBodyString("paste not found")
return
}
paste.DeletionToken = ""
legacyPaste := legacyFromModern(paste)
legacyPaste.DeletionToken = ""
// Respond with the paste
jsonData, err := json.Marshal(paste)
jsonData, err := json.Marshal(legacyPaste)
if err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
ctx.SetBodyString(err.Error())
@ -91,13 +92,13 @@ func v1PostPaste(ctx *fasthttp.RequestCtx) {
AutoDelete: config.Current.AutoDelete.Enabled,
}
// Set a deletion token
deletionToken := ""
if config.Current.DeletionTokens {
deletionToken = utils.RandomString(config.Current.DeletionTokenLength)
paste.DeletionToken = deletionToken
// Set a modification token
modificationToken := ""
if config.Current.ModificationTokens {
modificationToken = utils.RandomString(config.Current.ModificationTokenLength)
paste.ModificationToken = modificationToken
err = paste.HashDeletionToken()
err = paste.HashModificationToken()
if err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
ctx.SetBodyString(err.Error())
@ -114,8 +115,8 @@ func v1PostPaste(ctx *fasthttp.RequestCtx) {
}
// Respond with the paste
pasteCopy := *paste
pasteCopy.DeletionToken = deletionToken
pasteCopy := legacyFromModern(paste)
pasteCopy.DeletionToken = modificationToken
jsonData, err := json.Marshal(pasteCopy)
if err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
@ -139,9 +140,9 @@ func v1DeletePaste(ctx *fasthttp.RequestCtx) {
return
}
// Validate the deletion token of the paste
deletionToken := values["deletionToken"]
if deletionToken == "" {
// Validate the modification token of the paste
modificationToken := values["deletionToken"]
if modificationToken == "" {
ctx.SetStatusCode(fasthttp.StatusBadRequest)
ctx.SetBodyString("missing 'deletionToken' field")
return
@ -160,8 +161,8 @@ func v1DeletePaste(ctx *fasthttp.RequestCtx) {
return
}
// Check if the deletion token is correct
if (config.Current.DeletionTokenMaster == "" || deletionToken != config.Current.DeletionTokenMaster) && !paste.CheckDeletionToken(deletionToken) {
// Check if the modification token is correct
if (config.Current.ModificationTokenMaster == "" || modificationToken != config.Current.ModificationTokenMaster) && !paste.CheckModificationToken(modificationToken) {
ctx.SetStatusCode(fasthttp.StatusForbidden)
ctx.SetBodyString("invalid deletion token")
return

View File

@ -0,0 +1,276 @@
package v2
import (
"encoding/json"
"strings"
"time"
"github.com/fasthttp/router"
"github.com/lus/pasty/internal/config"
"github.com/lus/pasty/internal/report"
"github.com/lus/pasty/internal/shared"
"github.com/lus/pasty/internal/storage"
"github.com/lus/pasty/internal/utils"
limitFasthttp "github.com/ulule/limiter/v3/drivers/middleware/fasthttp"
"github.com/valyala/fasthttp"
)
// InitializePastesController initializes the '/v2/pastes/*' controller
func InitializePastesController(group *router.Group, rateLimiterMiddleware *limitFasthttp.Middleware) {
// moms spaghetti
group.GET("/{id}", rateLimiterMiddleware.Handle(middlewareInjectPaste(endpointGetPaste)))
group.POST("/", rateLimiterMiddleware.Handle(endpointCreatePaste))
group.PATCH("/{id}", rateLimiterMiddleware.Handle(middlewareInjectPaste(middlewareValidateModificationToken(endpointModifyPaste))))
group.DELETE("/{id}", rateLimiterMiddleware.Handle(middlewareInjectPaste(middlewareValidateModificationToken(endpointDeletePaste))))
if config.Current.Reports.Reports {
group.POST("/{id}/report", rateLimiterMiddleware.Handle(middlewareInjectPaste(endpointReportPaste)))
}
}
// middlewareInjectPaste retrieves and injects the paste with the specified ID
func middlewareInjectPaste(next fasthttp.RequestHandler) fasthttp.RequestHandler {
return func(ctx *fasthttp.RequestCtx) {
pasteID := ctx.UserValue("id").(string)
paste, err := storage.Current.Get(pasteID)
if err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
ctx.SetBodyString(err.Error())
return
}
if paste == nil {
ctx.SetStatusCode(fasthttp.StatusNotFound)
ctx.SetBodyString("paste not found")
return
}
if paste.Metadata == nil {
paste.Metadata = map[string]interface{}{}
}
ctx.SetUserValue("_paste", paste)
next(ctx)
}
}
// middlewareValidateModificationToken extracts and validates a given modification token for an injected paste
func middlewareValidateModificationToken(next fasthttp.RequestHandler) fasthttp.RequestHandler {
return func(ctx *fasthttp.RequestCtx) {
paste := ctx.UserValue("_paste").(*shared.Paste)
authHeaderSplit := strings.SplitN(string(ctx.Request.Header.Peek("Authorization")), " ", 2)
if len(authHeaderSplit) < 2 || authHeaderSplit[0] != "Bearer" {
ctx.SetStatusCode(fasthttp.StatusUnauthorized)
ctx.SetBodyString("unauthorized")
return
}
modificationToken := authHeaderSplit[1]
if config.Current.ModificationTokenMaster != "" && modificationToken == config.Current.ModificationTokenMaster {
next(ctx)
return
}
valid := paste.CheckModificationToken(modificationToken)
if !valid {
ctx.SetStatusCode(fasthttp.StatusUnauthorized)
ctx.SetBodyString("unauthorized")
return
}
next(ctx)
}
}
// endpointGetPaste handles the 'GET /v2/pastes/{id}' endpoint
func endpointGetPaste(ctx *fasthttp.RequestCtx) {
paste := ctx.UserValue("_paste").(*shared.Paste)
paste.DeletionToken = ""
paste.ModificationToken = ""
jsonData, err := json.Marshal(paste)
if err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
ctx.SetBodyString(err.Error())
return
}
ctx.SetBody(jsonData)
}
type endpointCreatePastePayload struct {
Content string `json:"content"`
Metadata map[string]interface{} `json:"metadata"`
}
// endpointCreatePaste handles the 'POST /v2/pastes' endpoint
func endpointCreatePaste(ctx *fasthttp.RequestCtx) {
// Read, parse and validate the request payload
payload := new(endpointCreatePastePayload)
if err := json.Unmarshal(ctx.PostBody(), payload); err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
ctx.SetBodyString(err.Error())
return
}
if payload.Content == "" {
ctx.SetStatusCode(fasthttp.StatusBadRequest)
ctx.SetBodyString("missing paste content")
return
}
if config.Current.LengthCap > 0 && len(payload.Content) > config.Current.LengthCap {
ctx.SetStatusCode(fasthttp.StatusBadRequest)
ctx.SetBodyString("too large paste content")
return
}
// Acquire a new paste ID
id, err := storage.AcquireID()
if err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
ctx.SetBodyString(err.Error())
return
}
// Prepare the paste object
if payload.Metadata == nil {
payload.Metadata = map[string]interface{}{}
}
paste := &shared.Paste{
ID: id,
Content: payload.Content,
Created: time.Now().Unix(),
AutoDelete: config.Current.AutoDelete.Enabled,
Metadata: payload.Metadata,
}
// 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)
}
type endpointModifyPastePayload struct {
Content *string `json:"content"`
Metadata map[string]interface{} `json:"metadata"`
}
// endpointModifyPaste handles the 'PATCH /v2/pastes/{id}' endpoint
func endpointModifyPaste(ctx *fasthttp.RequestCtx) {
// Read, parse and validate the request payload
payload := new(endpointModifyPastePayload)
if err := json.Unmarshal(ctx.PostBody(), payload); err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
ctx.SetBodyString(err.Error())
return
}
if payload.Content != nil && *payload.Content == "" {
ctx.SetStatusCode(fasthttp.StatusBadRequest)
ctx.SetBodyString("missing paste content")
return
}
if payload.Content != nil && config.Current.LengthCap > 0 && len(*payload.Content) > config.Current.LengthCap {
ctx.SetStatusCode(fasthttp.StatusBadRequest)
ctx.SetBodyString("too large paste content")
return
}
// Modify the paste itself
paste := ctx.UserValue("_paste").(*shared.Paste)
if payload.Content != nil {
paste.Content = *payload.Content
}
if payload.Metadata != nil {
for key, value := range payload.Metadata {
if value == nil {
delete(paste.Metadata, key)
continue
}
paste.Metadata[key] = value
}
}
// Save the modified paste
if err := storage.Current.Save(paste); err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
ctx.SetBodyString(err.Error())
return
}
}
// endpointDeletePaste handles the 'DELETE /v2/pastes/{id}' endpoint
func endpointDeletePaste(ctx *fasthttp.RequestCtx) {
paste := ctx.UserValue("_paste").(*shared.Paste)
if err := storage.Current.Delete(paste.ID); err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
ctx.SetBodyString(err.Error())
return
}
}
type endpointReportPastePayload struct {
Reason string `json:"reason"`
}
func endpointReportPaste(ctx *fasthttp.RequestCtx) {
// Read, parse and validate the request payload
payload := new(endpointReportPastePayload)
if err := json.Unmarshal(ctx.PostBody(), payload); err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
ctx.SetBodyString(err.Error())
return
}
if payload.Reason == "" {
ctx.SetStatusCode(fasthttp.StatusBadRequest)
ctx.SetBodyString("missing report reason")
return
}
request := &report.ReportRequest{
Paste: ctx.UserValue("_paste").(*shared.Paste).ID,
Reason: payload.Reason,
Timestamp: time.Now().Unix(),
}
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

@ -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"
@ -61,12 +62,25 @@ func Serve() error {
v1Route.GET("/info", func(ctx *fasthttp.RequestCtx) {
jsonData, _ := json.Marshal(map[string]interface{}{
"version": static.Version,
"deletionTokens": config.Current.DeletionTokens,
"deletionTokens": config.Current.ModificationTokens,
})
ctx.SetBody(jsonData)
})
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,
"reports": config.Current.Reports.Reports,
})
ctx.SetBody(jsonData)
})
v2.InitializePastesController(v2Route.Group("/pastes"), rateLimiterMiddleware)
}
}
// Route the hastebin documents route if hastebin support is enabled