diff --git a/README.md b/README.md index 217a315..c0d4c5d 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,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/internal/config/config.go b/internal/config/config.go index 008bf85..2172d2e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,7 @@ type Config struct { RateLimit string LengthCap int AutoDelete *AutoDeleteConfig + Reports *ReportConfig File *FileConfig Postgres *PostgresConfig MongoDB *MongoDBConfig @@ -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 @@ -83,6 +91,11 @@ func Load() { 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..320ade1 --- /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 +} + +// 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/web/controllers/v2/pastes.go b/internal/web/controllers/v2/pastes.go index 04d153c..4ef7829 100644 --- a/internal/web/controllers/v2/pastes.go +++ b/internal/web/controllers/v2/pastes.go @@ -7,6 +7,7 @@ import ( "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" @@ -21,6 +22,10 @@ func InitializePastesController(group *router.Group, rateLimiterMiddleware *limi 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 @@ -230,3 +235,42 @@ func endpointDeletePaste(ctx *fasthttp.RequestCtx) { 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 02d0146..d8d3d93 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -75,6 +75,7 @@ func Serve() error { jsonData, _ := json.Marshal(map[string]interface{}{ "version": static.Version, "modificationTokens": config.Current.ModificationTokens, + "reports": config.Current.Reports, }) ctx.SetBody(jsonData) })