diff --git a/API.md b/API.md new file mode 100644 index 0000000..fa3e62a --- /dev/null +++ b/API.md @@ -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 `, where `` 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 `, where `` 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. \ No newline at end of file diff --git a/README.md b/README.md index 099ca26..4bd6d40 100644 --- a/README.md +++ b/README.md @@ -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` | `` | `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` | `` | `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` | `` | `string` | Defines the webhook URL that is called whenever a paste is reported | +| `PASTY_REPORT_WEBHOOK_TOKEN` | `` | `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: diff --git a/cmd/transfer/main.go b/cmd/transfer/main.go index a54d0aa..f7b9a7d 100644 --- a/cmd/transfer/main.go +++ b/cmd/transfer/main.go @@ -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 { diff --git a/internal/config/config.go b/internal/config/config.go index a24999c..2172d2e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"), }, diff --git a/internal/report/report.go b/internal/report/report.go new file mode 100644 index 0000000..6f11528 --- /dev/null +++ b/internal/report/report.go @@ -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 +} diff --git a/internal/shared/paste.go b/internal/shared/paste.go index 374d68e..06e17b9 100644 --- a/internal/shared/paste.go +++ b/internal/shared/paste.go @@ -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 } diff --git a/internal/storage/mongodb/mongodb_driver.go b/internal/storage/mongodb/mongodb_driver.go index 2831422..5ba4af8 100644 --- a/internal/storage/mongodb/mongodb_driver.go +++ b/internal/storage/mongodb/mongodb_driver.go @@ -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 } diff --git a/internal/storage/postgres/migrations/000002_rename_deletion_token.down.sql b/internal/storage/postgres/migrations/000002_rename_deletion_token.down.sql new file mode 100644 index 0000000..357696e --- /dev/null +++ b/internal/storage/postgres/migrations/000002_rename_deletion_token.down.sql @@ -0,0 +1,5 @@ +begin; + +alter table if exists "pastes" rename column "modificationToken" to "deletionToken"; + +commit; \ No newline at end of file diff --git a/internal/storage/postgres/migrations/000002_rename_deletion_token.up.sql b/internal/storage/postgres/migrations/000002_rename_deletion_token.up.sql new file mode 100644 index 0000000..f420dd4 --- /dev/null +++ b/internal/storage/postgres/migrations/000002_rename_deletion_token.up.sql @@ -0,0 +1,5 @@ +begin; + +alter table if exists "pastes" rename column "deletionToken" to "modificationToken"; + +commit; \ No newline at end of file diff --git a/internal/storage/postgres/migrations/000003_add_metadata.down.sql b/internal/storage/postgres/migrations/000003_add_metadata.down.sql new file mode 100644 index 0000000..a9915b0 --- /dev/null +++ b/internal/storage/postgres/migrations/000003_add_metadata.down.sql @@ -0,0 +1,5 @@ +begin; + +alter table if exists "pastes" drop column "metadata"; + +commit; \ No newline at end of file diff --git a/internal/storage/postgres/migrations/000003_add_metadata.up.sql b/internal/storage/postgres/migrations/000003_add_metadata.up.sql new file mode 100644 index 0000000..be9090a --- /dev/null +++ b/internal/storage/postgres/migrations/000003_add_metadata.up.sql @@ -0,0 +1,5 @@ +begin; + +alter table if exists "pastes" add column "metadata" jsonb not null; + +commit; \ No newline at end of file diff --git a/internal/storage/postgres/postgres_driver.go b/internal/storage/postgres/postgres_driver.go index deb1d5f..b2f28a5 100644 --- a/internal/storage/postgres/postgres_driver.go +++ b/internal/storage/postgres/postgres_driver.go @@ -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 } diff --git a/internal/utils/randomString.go b/internal/utils/random_string.go similarity index 100% rename from internal/utils/randomString.go rename to internal/utils/random_string.go diff --git a/internal/web/controllers/v1/hastebin_support.go b/internal/web/controllers/v1/hastebin_support.go index e5c8283..08b1fbf 100644 --- a/internal/web/controllers/v1/hastebin_support.go +++ b/internal/web/controllers/v1/hastebin_support.go @@ -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()) diff --git a/internal/web/controllers/v1/paste_adapter.go b/internal/web/controllers/v1/paste_adapter.go new file mode 100644 index 0000000..a977b1d --- /dev/null +++ b/internal/web/controllers/v1/paste_adapter.go @@ -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, + } +} diff --git a/internal/web/controllers/v1/pastes.go b/internal/web/controllers/v1/pastes.go index ab47448..3e617ca 100644 --- a/internal/web/controllers/v1/pastes.go +++ b/internal/web/controllers/v1/pastes.go @@ -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 diff --git a/internal/web/controllers/v2/pastes.go b/internal/web/controllers/v2/pastes.go new file mode 100644 index 0000000..4ef7829 --- /dev/null +++ b/internal/web/controllers/v2/pastes.go @@ -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) +} diff --git a/internal/web/web.go b/internal/web/web.go index ee2411f..3c509b0 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" @@ -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