Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • whats-this/api
  • spotlightishere/api
  • bramhaag/api
  • easrng/api
4 results
Show changes
Commits on Source (38)
Showing
with 1736 additions and 134 deletions
root = true
[*]
end_of_line = lf
insert_final_newline = true
[*.go]
indent_style = tab
indent_size = 8
tab_width = 8
FROM golang:alpine
FROM golang:1.21-alpine
COPY go.mod /git/owo.codes/whats-this/api/
COPY go.sum /git/owo.codes/whats-this/api/
......@@ -7,9 +7,9 @@ COPY lib /git/owo.codes/whats-this/api/lib
RUN apk add --no-cache --virtual .build-deps git build-base && \
cd /git/owo.codes/whats-this/api && \
go mod download && \
go build main.go && \
apk del .build-deps
WORKDIR /git/owo.codes/whats-this/api
ENTRYPOINT ["./main"]
# OwO API
Go web application powered by fasthttp which handles file uploads (Pomf) and
link shortens (Polr) on OwO.
Go web application powered by go-chi which handles file uploads (Pomf) and link
shortens (Polr) on OwO.
### Setup
1. Setup `config.toml` and move to `/etc/whats-this/api/config.toml`
......@@ -12,4 +12,3 @@ Or use Docker if you want.
### License
A copy of the MIT license can be found in LICENSE.
......@@ -14,6 +14,18 @@ resultURL = "https://awau.moe/"
[pomf]
maxFilesPerUpload = 3
[files]
tempLocation = "/var/data/buckets/_temp"
storageLocation = "/var/data/buckets/public"
storageLocation = "/var/data/buckets/data"
quarantineLocation = "/var/data/buckets/_quarantine"
[fileWebhook]
# URL will receive POST with JSON body containing only a `sha256_hash` key for each NEW file
enable = false
url = "http://localhost:3100/scan"
[ratelimiter]
enable = true
redisURL = "redis://localhost:6379/0"
module owo.codes/whats-this/api
go 1.21
require (
github.com/akamensky/base58 v0.0.0-20210829145138-ce8bf8802e8f
github.com/go-chi/chi/v5 v5.0.10
github.com/go-chi/render v1.0.3
github.com/go-redis/redis v6.15.9+incompatible
github.com/gofrs/uuid v4.4.0+incompatible
github.com/lib/pq v1.10.9
github.com/pkg/errors v0.9.1
github.com/rs/zerolog v1.31.0
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.17.0
)
require (
github.com/akamensky/base58 v0.0.0-20170920141933-92b0f56f531a
github.com/go-chi/chi v4.0.1+incompatible
github.com/go-chi/render v1.0.1
github.com/go-chi/valve v0.0.0-20170920024740-9e45288364f4
github.com/gofrs/uuid v3.2.0+incompatible
github.com/lib/pq v1.0.0
github.com/o1egl/paseto v1.0.0
github.com/pkg/errors v0.8.1
github.com/rs/zerolog v1.11.0
github.com/satori/go.uuid v1.2.0
github.com/spf13/pflag v1.0.3
github.com/spf13/viper v1.3.1
golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2
github.com/ajg/form v1.5.1 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/onsi/ginkgo v1.16.5 // indirect
github.com/onsi/gomega v1.18.1 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.3.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.10.0 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect
golang.org/x/sys v0.14.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
This diff is collapsed.
......@@ -33,13 +33,37 @@ var (
InvalidOffsetOrLimit = APIError{false, 400, "invalid offset or limit query paramters", false}
// OffsetTooLarge is a 400 bad request error.
OffsetTooLarge = APIError{false, 400, "offset is too big", false}
LimitTooLarge = APIError{false, 400, "limit is too big", false}
// NoObjectFound is a 404 not found error.
NoObjectFound = APIError{false, 404, "no object found", false}
// AlreadyDeleted is a 410 gone error.
AlreadyDeleted = APIError{false, 410, "already deleted", false}
// InsufficientTokens is a 429 too many requests error.
InsufficientTokens = APIError{false, 429, "too many requests", false}
// BadFileID is a 400 bad request error.
BadFileID = APIError{false, 400, "bad file ID", false}
// InvalidBanFileReason is a 400 bad request error.
InvalidBanFileReason = APIError{false, 400, "invalid reason, must be an integer from 0 to 3 inclusive", false}
// InvalidBanFileMalwareName is a 400 bad request error.
InvalidBanFileMalwareName = APIError{false, 400, "malware_name is required when reason is 1 (malware)", false}
// FileIsAlreadyBanned is a 409 conflict error.
FileIsAlreadyBanned = APIError{false, 409, "file is already banned", false}
// CannotQuarantineDueToNoMatchingObjects is a 404 not found error.
CannotQuarantineDueToNoMatchingObjects = APIError{false, 404, "cannot quarantine due to no matching objects", false}
// FileIsNotBanned is a 404 not found error.
FileIsNotBanned = APIError{false, 404, "specified file is not banned", false}
// InvalidObjectFilter is a 400 bad request error.
InvalidObjectFilter = APIError{false, 400, `invalid filter, must be "", "files" or "links"`, false}
)
// Pomf errors
......@@ -61,6 +85,9 @@ var (
// InternalServerError is a 500 internal server error.
InternalServerError = APIError{false, 500, "internal server error", false}
// FileIsBanned is a 409 conflict error.
FileIsBanned = APIError{false, 409, "file is banned", false}
)
// Polr errors
......
......@@ -4,27 +4,44 @@ import "time"
// User represents a user from the database.
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"`
IsBlocked bool `json:"is_blocked"`
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"`
IsBlocked bool `json:"is_blocked"`
BucketCapacity int64 `json:"-"`
}
// Object represents an object from the database.
type Object struct {
Bucket string `json:"bucket"`
Key string `json:"key"`
Directory string `json:"dir"`
Type int `json:"type"`
DestURL *string `json:"dest_url"`
ContentType *string `json:"content_type"`
ContentLength *int64 `json:"content_length"`
CreatedAt time.Time `json:"created_at"`
DeletedAt *time.Time `json:"deleted_at"`
DeleteReason *string `json:"delete_reason"`
AssociatedUser *string `json:"-"`
MD5Hash *string `json:"md5_hash"`
Bucket string `json:"bucket"`
Key string `json:"key"`
Directory string `json:"dir"`
Type int `json:"type"`
DestURL *string `json:"dest_url"`
ContentType *string `json:"content_type"`
ContentLength *int64 `json:"content_length"`
CreatedAt time.Time `json:"created_at"`
DeletedAt *time.Time `json:"deleted_at"`
DeleteReason *string `json:"delete_reason"`
AssociatedUser *string `json:"-"`
MD5HashBytes []byte `json:"-"`
SHA256HashBytes []byte `json:"-"`
AssociatedWithCurrentUser *bool `json:"associated_with_current_user,omitempty"`
// Computed fields
MD5Hash *string `json:"md5_hash"`
SHA256Hash *string `json:"sha256_hash"`
AssociatedWithCurrentUser *bool `json:"associated_with_current_user,omitempty"`
}
// FileBan represents a banned file from the database.
type FileBan struct {
SHA256HashBytes []byte `json:"-"`
DidQuarantine bool `json:"did_quarantine"`
Reason int `json:"reason"`
Description *string `json:"description"`
MalwareName *string `json:"malware_name"`
// Computed fields
SHA256Hash *string `json:"sha256_hash"`
}
......@@ -2,6 +2,7 @@ package db
import (
"database/sql"
"encoding/hex"
"fmt"
"strings"
......@@ -12,17 +13,23 @@ import (
// SelectUserByToken returns a user object from a token.
func SelectUserByToken(token string) (User, error) {
var user User
var bucketCapacity sql.NullInt64
err := DB.QueryRow(selectUserByToken, token).
Scan(&user.ID, &user.Username, &user.Email, &user.IsAdmin, &user.IsBlocked)
Scan(&user.ID, &user.Username, &user.Email, &user.IsAdmin, &user.IsBlocked, &bucketCapacity)
if err != nil {
return user, err
}
if bucketCapacity.Valid {
user.BucketCapacity = bucketCapacity.Int64
}
return user, err
}
// ObjectKeyExists returns a boolean specifying if the given key is already in
// use by another object.
func ObjectKeyExists(key string) (bool, error) {
func ObjectKeyExists(bucket, key string) (bool, error) {
var count int
err := DB.QueryRow(countOfObjectByBucketAndRandomKey, "public", key).
Scan(&count)
err := DB.QueryRow(countOfObjectByBucketAndRandomKey, bucket, key).Scan(&count)
if err != nil {
return false, err
}
......@@ -31,7 +38,16 @@ func ObjectKeyExists(key string) (bool, error) {
// InsertShortURL inserts a short URL object into the database.
func InsertShortURL(bucket, key, destURL string, associatedUser *string) error {
result, err := DB.Exec(insertShortURL, fmt.Sprintf("%s/%s", bucket, key), key, destURL, associatedUser)
if !strings.HasPrefix(key, "/") {
key = "/" + key
}
result, err := DB.Exec(insertShortURL,
bucket+key,
bucket,
key,
key[1:],
destURL,
associatedUser)
if err != nil {
return err
}
......@@ -46,14 +62,19 @@ func InsertShortURL(bucket, key, destURL string, associatedUser *string) error {
}
// InsertFile inserts a file object into the database.
func InsertFile(bucket, key, ext, contentType string, contentLength int64, md5Hash string, associatedUser *string) error {
func InsertFile(bucket, key, ext, contentType string, contentLength int64, md5Hash, sha256Hash []byte, associatedUser *string) error {
if !strings.HasPrefix(key, "/") {
key = "/" + key
}
result, err := DB.Exec(insertFile,
fmt.Sprintf("%s/%s", bucket, key+ext),
bucket+key+ext,
bucket,
key+ext,
key,
key[1:],
contentType,
contentLength,
md5Hash,
sha256Hash,
associatedUser)
if err != nil {
return err
......@@ -128,7 +149,8 @@ func scanObject(scanner scanner) (Object, error) {
var deletedAt pq.NullTime
var deleteReason sql.NullString
var associatedUser sql.NullString
var md5Hash sql.NullString
var md5Hash []byte
var sha256Hash []byte
err := scanner.Scan(&object.Bucket,
&object.Key,
&object.Directory,
......@@ -140,32 +162,52 @@ func scanObject(scanner scanner) (Object, error) {
&deletedAt,
&deleteReason,
&md5Hash,
&sha256Hash,
&associatedUser)
if err != nil {
return Object{}, errors.Wrap(err, "failed to Scan row from query")
}
if object.Type < 0 || object.Type > 2 {
return Object{}, errors.Wrap(err, "invalid Object scanned from query")
}
if object.Type != 1 {
if md5Hash != nil && len(md5Hash) == 16 {
object.MD5HashBytes = md5Hash
md5String := hex.EncodeToString(md5Hash)
object.MD5Hash = &md5String
}
if sha256Hash != nil && len(sha256Hash) == 32 {
object.SHA256HashBytes = sha256Hash
sha256String := hex.EncodeToString(sha256Hash)
object.SHA256Hash = &sha256String
}
}
if object.Type == 0 {
if !contentType.Valid || !contentLength.Valid || !md5Hash.Valid {
if !contentType.Valid || !contentLength.Valid || object.MD5HashBytes == nil || object.SHA256HashBytes == nil {
return Object{}, errors.Wrap(err, "invalid Object scanned from query")
}
object.ContentType = &contentType.String
object.ContentLength = &contentLength.Int64
object.MD5Hash = &md5Hash.String
} else if object.Type == 1 {
}
if object.Type == 1 {
if !destURL.Valid {
return Object{}, errors.Wrap(err, "invalid Object scanned from query")
}
object.DestURL = &destURL.String
}
if deletedAt.Valid {
object.DeletedAt = &deletedAt.Time
if deleteReason.Valid {
object.DeleteReason = &deleteReason.String
}
} else {
if deleteReason.Valid {
return Object{}, errors.Wrap(err, "invalid Object scanned from query")
if object.Type == 2 {
if deletedAt.Valid {
object.DeletedAt = &deletedAt.Time
if deleteReason.Valid {
object.DeleteReason = &deleteReason.String
}
} else {
if deleteReason.Valid {
return Object{}, errors.Wrap(err, "invalid Object scanned from query")
}
}
} else if deletedAt.Valid || deleteReason.Valid {
return Object{}, errors.Wrap(err, "invalid Object scanned from query")
}
if associatedUser.Valid {
object.AssociatedUser = &associatedUser.String
......@@ -175,13 +217,19 @@ func scanObject(scanner scanner) (Object, error) {
// ListObjectsByAssociatedUser returns all objects (paginated) associated with a
// user.
func ListObjectsByAssociatedUser(userID string, asc bool, offset, limit int) ([]Object, error) {
func ListObjectsByAssociatedUser(userID string, typ int, asc bool, offset, limit int) ([]Object, error) {
objects := []Object{}
order := "DESC"
if asc {
order = "ASC"
}
rows, err := DB.Query(fmt.Sprintf(listObjectsByAssociatedUser, order), userID, limit, offset)
typeFilter := "!= 2"
if typ != -1 {
typeFilter = fmt.Sprintf("= %v", typ)
}
rows, err := DB.Query(fmt.Sprintf(listObjectsByAssociatedUser, typeFilter, order), userID, limit, offset)
if err != nil {
return objects, err
}
......@@ -198,15 +246,163 @@ func ListObjectsByAssociatedUser(userID string, asc bool, offset, limit int) ([]
return objects, nil
}
// CountObjectsByAssociatedUser returns the count of all objects associated with a user.
func CountObjectsByAssociatedUser(userID string, typ int) (int, error) {
typeFilter := "!= 2"
if typ != -1 {
typeFilter = fmt.Sprintf("= %v", typ)
}
var count int
query := fmt.Sprintf(countObjectsByAssociatedUser, typeFilter)
err := DB.QueryRow(query, userID).Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}
// GetObject returns an object.
func GetObject(bucket, key string) (Object, error) {
row := DB.QueryRow(getObjectByBucketKey, fmt.Sprintf("%s/%s", bucket, key))
return scanObject(row)
}
// UpdateObjectToTombstone sets an object to be a tombstone.
func UpdateObjectToTombstone(bucket, key string, reason *string) error {
result, err := DB.Exec(updateObjectToTombstone, reason, fmt.Sprintf("%s/%s", bucket, key))
// CheckIfObjectExists returns true if an object exists with the specified hash.
func CheckIfObjectExists(sha256 []byte) (bool, error) {
var count int
err := DB.QueryRow(countOfObjectsBySHA256, sha256).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// UpdateObjectToTombstoneByBucketKey sets an object to be a tombstone by bucket and key.
func UpdateObjectToTombstoneByBucketKey(bucket, key string, reason *string, retainAssociatedUser bool, retainHashes bool) error {
sql := updateObjectToTombstoneByBucketKey
if retainAssociatedUser {
sql = updateObjectToTombstoneKeepAssociatedUserByBucketKey
if retainHashes {
sql = updateObjectToTombstoneKeepHashesAndAssociatedUserByBucketKey
}
}
result, err := DB.Exec(sql, reason, fmt.Sprintf("%s/%s", bucket, key))
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows != 1 {
return errors.Errorf("unexpected amount of rows affected: expected 1, got %v", rows)
}
return nil
}
// UpdateObjectToTombstoneBySHA256Hash sets all objects with a matching SHA256 hash to be a tombstone.
func UpdateObjectToTombstoneBySHA256Hash(sha256 []byte, reason *string, retainAssociatedUser bool, retainHashes bool) error {
sql := updateObjectToTombstoneBySHA256Hash
if retainAssociatedUser {
sql = updateObjectToTombstoneKeepAssociatedUserBySHA256Hash
if retainHashes {
sql = updateObjectToTombstoneKeepHashesAndAssociatedUserBySHA256Hash
}
}
_, err := DB.Exec(sql, reason, sha256)
if err != nil {
return err
}
return nil
}
// CheckIfFileBanExists returns a boolean specifying if the given key is already
// in use by another object.
func CheckIfFileBanExists(sha256 []byte) (bool, error) {
var count int
err := DB.QueryRow(countOfFileBanBySHA256, sha256).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// InsertFileBan inserts a file ban into the database.
func InsertFileBan(sha256 []byte, didQuarantine bool, reason int, description, malwareName *string) error {
if reason < 0 || reason > 3 {
return errors.New("invalid file ban reason")
}
if malwareName != nil && *malwareName == "" {
malwareName = nil
}
result, err := DB.Exec(insertFileBan, sha256, didQuarantine, reason, description, malwareName)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows != 1 {
return errors.Errorf("unexpected amount of rows affected: expected 1, got %v", rows)
}
return nil
}
// scanFileBan scans an file_bans row into an FileBan.
func scanFileBan(scanner scanner) (FileBan, error) {
fileBan := FileBan{}
err := scanner.Scan(&fileBan.SHA256HashBytes,
&fileBan.DidQuarantine,
&fileBan.Reason,
&fileBan.Description,
&fileBan.MalwareName)
if err != nil {
return FileBan{}, errors.Wrap(err, "failed to Scan row from query")
}
if fileBan.Reason < 0 || fileBan.Reason > 3 {
return FileBan{}, errors.Wrap(err, "invalid FileBan scanned from query")
}
if fileBan.Reason != 1 && fileBan.MalwareName != nil {
return FileBan{}, errors.Wrap(err, "invalid FileBan scanned from query")
}
if fileBan.SHA256HashBytes != nil && len(fileBan.SHA256HashBytes) == 32 {
sha256String := hex.EncodeToString(fileBan.SHA256HashBytes)
fileBan.SHA256Hash = &sha256String
}
return fileBan, nil
}
// ListBannedFiles returns all banned files.
func ListBannedFiles() ([]FileBan, error) {
bannedFiles := []FileBan{}
rows, err := DB.Query(listBannedFiles)
if err != nil {
return bannedFiles, err
}
defer rows.Close()
// Scan into bannedFiles
for rows.Next() {
bannedFile, err := scanFileBan(rows)
if err != nil {
return []FileBan{}, err
}
bannedFiles = append(bannedFiles, bannedFile)
}
return bannedFiles, nil
}
// GetBannedFile returns an FileBan.
func GetBannedFile(sha256 []byte) (FileBan, error) {
row := DB.QueryRow(getBannedFileBySHA256Hash, sha256)
return scanFileBan(row)
}
// DeleteBannedFile deletes a banned file from the database, effectively unbanning it.
func DeleteBannedFile(sha256 []byte) error {
result, err := DB.Exec(deleteFileBan, sha256)
if err != nil {
return err
}
......
......@@ -6,7 +6,8 @@ SELECT
username,
email,
is_admin,
is_blocked
is_blocked,
bucket_capacity
FROM
users u,
tokens t
......@@ -30,14 +31,14 @@ var insertShortURL = `
INSERT INTO
objects (bucket_key, bucket, key, random_key, dir, type, dest_url, content_type, associated_user)
VALUES
($1, 'public', $2, $2, '/', 1, $3, NULL, $4)
($1, $2, $3, $4, '/', 1, $5, NULL, $6)
`
var insertFile = `
INSERT INTO
objects (bucket_key, bucket, key, random_key, dir, content_type, content_length, md5_hash, associated_user)
objects (bucket_key, bucket, key, random_key, dir, content_type, content_length, md5_hash, sha256_hash, associated_user)
VALUES
($1, 'public', $2, $3, '/', $4, $5, $6, $7)
($1, $2, $3, $4, '/', $5, $6, $7, $8, $9)
`
var selectUserByUsernameOrEmail = `
......@@ -64,6 +65,16 @@ VALUES
($1, $2)
`
var countObjectsByAssociatedUser = `
SELECT
COUNT(*) as count
FROM
objects
WHERE
associated_user = $1 AND
"type" %v
`
var listObjectsByAssociatedUser = `
SELECT
bucket,
......@@ -77,11 +88,13 @@ SELECT
deleted_at,
delete_reason,
md5_hash,
sha256_hash,
associated_user
FROM
objects
WHERE
associated_user = $1
associated_user = $1 AND
"type" %v
ORDER BY
created_at %s
LIMIT $2
......@@ -101,6 +114,7 @@ SELECT
deleted_at,
delete_reason,
md5_hash,
sha256_hash,
associated_user
FROM
objects
......@@ -109,7 +123,17 @@ WHERE
LIMIT 1
`
var updateObjectToTombstone = `
var countOfObjectsBySHA256 = `
SELECT
COUNT(*)
FROM
objects
WHERE
"type" = 0 AND
sha256_hash = $1
`
var updateObjectToTombstoneByBucketKey = `
UPDATE
objects
SET
......@@ -120,7 +144,133 @@ SET
content_length = NULL,
delete_reason = $1,
md5_hash = NULL,
sha256_hash = NULL,
associated_user = NULL
WHERE
bucket_key = $2
`
var updateObjectToTombstoneKeepAssociatedUserByBucketKey = `
UPDATE
objects
SET
type = 2,
deleted_at = CURRENT_TIMESTAMP,
dest_url = NULL,
content_type = NULL,
content_length = NULL,
delete_reason = $1,
md5_hash = NULL,
sha256_hash = NULL
WHERE
bucket_key = $2
`
var updateObjectToTombstoneKeepHashesAndAssociatedUserByBucketKey = `
UPDATE
objects
SET
type = 2,
deleted_at = CURRENT_TIMESTAMP,
dest_url = NULL,
content_type = NULL,
content_length = NULL,
delete_reason = $1
WHERE
bucket_key = $2
`
var updateObjectToTombstoneBySHA256Hash = `
UPDATE
objects
SET
type = 2,
deleted_at = CURRENT_TIMESTAMP,
dest_url = NULL,
content_type = NULL,
content_length = NULL,
delete_reason = $1,
md5_hash = NULL,
sha256_hash = NULL,
associated_user = NULL
WHERE
sha256_hash = $2
`
var updateObjectToTombstoneKeepAssociatedUserBySHA256Hash = `
UPDATE
objects
SET
type = 2,
deleted_at = CURRENT_TIMESTAMP,
dest_url = NULL,
content_type = NULL,
content_length = NULL,
delete_reason = $1,
md5_hash = NULL,
sha256_hash = NULL
WHERE
sha256_hash = $2
`
var updateObjectToTombstoneKeepHashesAndAssociatedUserBySHA256Hash = `
UPDATE
objects
SET
type = 2,
deleted_at = CURRENT_TIMESTAMP,
dest_url = NULL,
content_type = NULL,
content_length = NULL,
delete_reason = $1
WHERE
sha256_hash = $2
`
var countOfFileBanBySHA256 = `
SELECT
COUNT(*) as count
FROM
file_bans
WHERE
sha256_hash = $1
`
var insertFileBan = `
INSERT INTO
file_bans (sha256_hash, did_quarantine, reason, description, malware_name)
VALUES
($1, $2, $3, $4, $5)
`
var listBannedFiles = `
SELECT
sha256_hash,
did_quarantine,
reason,
description,
malware_name
FROM
file_bans
`
var getBannedFileBySHA256Hash = `
SELECT
sha256_hash,
did_quarantine,
reason,
description,
malware_name
FROM
file_bans
WHERE
sha256_hash = $1
LIMIT 1
`
var deleteFileBan = `
DELETE FROM
file_bans
WHERE
sha256_hash = $1
`
......@@ -9,18 +9,23 @@ import (
"owo.codes/whats-this/api/lib/apierrors"
"owo.codes/whats-this/api/lib/db"
"owo.codes/whats-this/api/lib/ratelimiter"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
)
// UUIDRegex is a UUID regex. See http://stackoverflow.com/a/13653180.
var UUIDRegex = regexp.MustCompile("^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
type authKey struct{}
type authKey string
// AuthorizedUserKey is the context value key for storing request authorization
// information.
var AuthorizedUserKey authKey
var AuthorizedUserKey = authKey("AuthorizedUserKey")
// BucketKey is the context value key for storing the request ratelimit bucket.
var BucketKey = authKey("BucketKey")
// Authenticator creates middleware that authenticates incoming requests using
// a token and checking it against the database.
......@@ -59,6 +64,16 @@ func Authenticator(next http.Handler) http.Handler {
panic(apierrors.Unauthorized)
}
ctx = context.WithValue(ctx, AuthorizedUserKey, user)
// Create a bucket and add to request
if viper.GetBool("ratelimiter.enable") {
bucketCapacity := user.BucketCapacity
if bucketCapacity < 0 {
bucketCapacity = 0
}
bucket := ratelimiter.NewBucket(user.ID, bucketCapacity, 0)
ctx = context.WithValue(ctx, BucketKey, bucket)
}
next.ServeHTTP(w, r.WithContext(ctx))
}
return http.HandlerFunc(fn)
......@@ -69,3 +84,15 @@ func GetAuthorizedUser(r *http.Request) db.User {
user, _ := r.Context().Value(AuthorizedUserKey).(db.User)
return user
}
// GetBucket returns the current authorized user's ratelimit bucket.
func GetBucket(r *http.Request) ratelimiter.Bucket {
if !viper.GetBool("ratelimiter.enable") {
return ratelimiter.NoopBucket
}
bucket, ok := r.Context().Value(BucketKey).(ratelimiter.Bucket)
if !ok || bucket == nil {
return ratelimiter.EmptyBucket
}
return bucket
}
package ratelimiter
import (
"fmt"
"net/http"
"strconv"
"sync"
"time"
"github.com/go-redis/redis"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
)
// Redis key format string for bucket ID.
const redisKeyFormat = "whats-this:api:bucket:%s"
// onceNoCapacityWarning is used for creating a one-time warning in
// NewBucketWithRedis.
var onceNoCapacityWarning sync.Once
// onceNoExpiryWarning is used for creating a one-time warning in
// NewBucketWithRedis.
var onceNoExpiryWarning sync.Once
// Bucket is an interface that implements token bucket methods.
type Bucket interface {
Take(tokens int64) error
TakeWithHeaders(w http.ResponseWriter, tokens int64) error
Reset() error
}
// noopBucket is a bucket that does nothing for use when ratelimiting is
// disabled (infinite capacity bucket).
type noopBucket struct{}
// Take implements Bucket (noop).
func (b *noopBucket) Take(tokens int64) error {
return nil
}
// TakeWithHeaders implements Bucket (noop).
func (b *noopBucket) TakeWithHeaders(w http.ResponseWriter, tokens int64) error {
return nil
}
// Reset implements Bucket (noop).
func (b *noopBucket) Reset() error {
return nil
}
// NoopBucket is a reusable noopBucket (Bucket that has infinite capacity).
var NoopBucket Bucket = &noopBucket{}
// emptyBucket is a bucket that is always empty.
type emptyBucket struct{}
// Take implements Bucket (always empty).
func (b *emptyBucket) Take(tokens int64) error {
return InsufficientTokens
}
// TakeWithHeaders implements Bucket (always empty).
func (b *emptyBucket) TakeWithHeaders(w http.ResponseWriter, tokens int64) error {
return InsufficientTokens
}
// Reset implements Bucket (always empty).
func (b *emptyBucket) Reset() error {
return nil
}
// EmptyBucket is a reusable emptyBucket (Bucket that is always empty).
var EmptyBucket Bucket = &emptyBucket{}
// RedisBucket represents a named bucket stored in Redis with a set number of
// tokens.
type RedisBucket struct {
r *redis.Client
ID string
Capacity int64
Expiry time.Duration
}
// NewBucket creates a Bucket with the default Redis client. If the capacity is
// set to 0, the limit will be set to the default limit in the configuration.
func NewBucket(id string, capacity int64, expiry time.Duration) Bucket {
return NewBucketWithRedis(rc, id, capacity, expiry)
}
// NewBucketWithRedis creates a Bucket with a custom Redis client. If the
// capacity is set to 0, the limit will be set to the default limit in the
// configuration.
func NewBucketWithRedis(client *redis.Client, id string, capacity int64, expiry time.Duration) Bucket {
if client == nil {
panic("Invalid Redis client supplied")
}
if id == "" {
panic("id should be specified")
}
if capacity == 0 {
// Get default bucket capacity from Viper
if c := viper.GetInt64("ratelimiter.defaultBucketCapacity"); c > 0 {
capacity = c
} else {
onceNoCapacityWarning.Do(func() {
log.Warn().Msg("RedisBucket made with no capacity, returning a noopBucket " +
"(infinite capacity Bucket)")
})
return NoopBucket
}
}
if capacity < 0 {
panic("RedisBucket limit is less than 0")
}
if expiry == 0 {
// Get default bucket expiry from Viper
if e := viper.GetDuration("ratelimiter.bucketExpiryDuration"); e > 0 {
expiry = e
} else {
onceNoExpiryWarning.Do(func() {
log.Warn().Msg("RedisBucket made with no expiry duration, returning a noopBucket " +
"(infinite capacity Bucket)")
})
return NoopBucket
}
}
if expiry < 0 {
panic("RedisBucket expiry is less than 0")
}
return &RedisBucket{
r: client,
ID: id,
Capacity: capacity,
Expiry: expiry,
}
}
// key returns the key for this Bucket in Redis.
func (b *RedisBucket) key() string {
return fmt.Sprintf(redisKeyFormat, b.ID)
}
// Take implements Bucket for Redis.
func (b *RedisBucket) Take(tokens int64) error {
_, _, err := b.take(tokens)
return err
}
// TakeWithHeaders implements Bucket for Redis.
func (b *RedisBucket) TakeWithHeaders(w http.ResponseWriter, tokens int64) error {
current, expiry, err := b.take(tokens)
if err != nil && err != InsufficientTokens {
return err
}
if current < 0 {
// Don't set headers if there are negative tokens in the bucket
return nil
}
w.Header().Set("X-Ratelimiter-Capacity", strconv.Itoa(int(b.Capacity)))
w.Header().Set("X-Ratelimiter-Remaining", strconv.Itoa(int(current)))
w.Header().Set("X-Ratelimiter-Cost", strconv.Itoa(int(tokens)))
expirySeconds := time.Now().Add(expiry).UTC().Unix()
w.Header().Set("X-Ratelimiter-Expiry", strconv.Itoa(int(expirySeconds)))
return err
}
// take is the internal take function used by TakeWithHeaders and Take.
func (b *RedisBucket) take(tokens int64) (int64, time.Duration, error) {
key := b.key()
currentStr, err := b.r.Get(key).Result()
var current int64
if err != nil && err != redis.Nil {
return -1, 0, err
}
expiry := b.Expiry
if err == redis.Nil {
_, err = b.r.Set(key, b.Capacity, b.Expiry).Result()
if err != nil {
return -1, 0, err
}
current = b.Capacity
} else {
c, err := strconv.Atoi(currentStr)
if err != nil {
return -1, 0, err
}
current = int64(c)
// Get key TTL
expiry, err = b.r.TTL(key).Result()
if err != nil {
return -1, 0, err
}
}
if current < tokens {
return current, expiry, InsufficientTokens
}
_, err = b.r.IncrBy(key, -tokens).Result()
if err != nil {
return current, expiry, err
}
return current - tokens, expiry, nil
}
// Reset implements Bucket for Redis.
func (b *RedisBucket) Reset() error {
_, err := b.r.Set(b.key(), b.Capacity, b.Expiry).Result()
return err
}
package ratelimiter
import (
"github.com/go-redis/redis"
)
var rc *redis.Client
// RedisConnect connects to Redis and sets the default ratelimiter connection
// for RedisBuckets (use NewBucket to make a RedisBucket with the default
// connection).
func RedisConnect(url string) error {
opt, err := redis.ParseURL(url)
if err != nil {
return err
}
r := redis.NewClient(opt)
_, err = r.Ping().Result()
if err != nil {
return err
}
rc = r
return nil
}
package ratelimiter
// Default costs for each route.
const (
UploadPomfCost = 10
ShortenPolrCost = 5
// CreateUserCost = 0 // unused
MeCost = 3
ListObjectsCost = 10
ObjectCost = 3
DeleteObjectCost = 5
)
package ratelimiter
type bucketError struct {
Err string
}
// Error implements error.
func (e *bucketError) Error() string {
return e.Err
}
// InsufficientTokens means the requested amount of tokens can't be taken
// because there isn't enough tokens in the bucket at the moment.
var InsufficientTokens error = &bucketError{"insufficient tokens in Bucket for Take operation"}
package routes
import (
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"regexp"
"owo.codes/whats-this/api/lib/apierrors"
"owo.codes/whats-this/api/lib/db"
"owo.codes/whats-this/api/lib/middleware"
"github.com/go-chi/render"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
)
// banFileBody is the request body for a create user request.
type banFileBody struct {
ID string `json:"id"` // "sha256:abcdef..." or "object:bucket/key"
QuarantineSample bool `json:"quarantine_sample"` // move file to quarantine instead of deleting it
Reason int `json:"reason"` // 0=other, 1=malware, 2=takedown, 3=tos_violation
Description *string `json:"other_description"` // optional
MalwareName *string `json:"malware_name"` // required, only when Reason == 1
}
// createUserResponse is the response body for a successful create user request.
type banFileResponse struct {
Success bool `json:"success"`
StatusCode *int `json:"errorcode"`
SHA256 string `json:"sha256"`
}
// File ID regexes
var sha256IDRegex = regexp.MustCompile(`^sha256:([a-fA-F0-9]{64})$`)
var objectIDRegex = regexp.MustCompile(`^object:(.+)/(.*)$`)
// BanFile handles file banning requests. A banned file (identified by SHA256 hash) cannot be uploaded by any user.
// Optionally, existing objects representing this file can be turned into tombstones, and the file deleted from the
// server. If this happens, optionally a sample of the file can be retained in the quarantine bucket.
func BanFile(w http.ResponseWriter, r *http.Request) {
// Only authorized admin users can use this route
user := middleware.GetAuthorizedUser(r)
if user.ID == "" || user.IsBlocked || !user.IsAdmin {
panic(apierrors.Unauthorized)
}
// Parse request body
decoder := json.NewDecoder(r.Body)
var body banFileBody
err := decoder.Decode(&body)
if err != nil {
panic(apierrors.InvalidJSONPayload)
}
// Try to get the SHA256 hash of the file
var sha256 []byte
var object db.Object
if match := sha256IDRegex.FindStringSubmatch(body.ID); len(match) == 2 {
sha256, err = hex.DecodeString(match[1])
if err != nil {
panic(apierrors.BadFileID)
}
} else if match := objectIDRegex.FindStringSubmatch(body.ID); len(match) == 3 {
object, err = db.GetObject(match[1], match[2])
switch {
case errors.Cause(err) == sql.ErrNoRows:
panic(apierrors.NoObjectFound)
case err != nil:
log.Error().Err(err).Msg("failed to get object")
panic(apierrors.InternalServerError)
}
if object.Type != 0 { // file
panic(apierrors.BadFileID)
}
sha256 = object.SHA256HashBytes
} else {
panic(apierrors.BadFileID)
}
// Validate request body
if body.Reason < 0 || body.Reason > 3 {
panic(apierrors.InvalidBanFileReason)
}
if body.Reason == 1 && (body.MalwareName == nil || *body.MalwareName == "") {
panic(apierrors.InvalidBanFileMalwareName)
}
// Check if file is already banned
exists, err := db.CheckIfFileBanExists(sha256)
if err != nil {
log.Error().Err(err).Msg("failed to check if file ban exists")
panic(apierrors.InternalServerError)
}
if exists {
panic(apierrors.FileIsAlreadyBanned)
}
// Quarantine file if specified
if body.QuarantineSample {
// Move object to quarantine
sha256String := hex.EncodeToString(sha256)
origPath := filepath.Join(viper.GetString("files.storageLocation"), sha256String)
destPath := filepath.Join(viper.GetString("files.quarantineLocation"), sha256String)
err = os.Rename(origPath, destPath)
if os.IsNotExist(err) {
panic(apierrors.CannotQuarantineDueToNoMatchingObjects)
}
if err != nil {
log.Error().Err(err).Msg("failed to move file to quarantine")
panic(apierrors.InternalServerError)
}
}
// Delete objects referencing the object
reason := "file banned by administrator by SHA256 hash"
switch body.Reason {
case 1: // malware
reason = fmt.Sprintf("file banned for containing malware (%s)", *body.MalwareName)
case 2: // takedown
reason += " due to a takedown request"
case 3: // tos_violation
reason += " due to a TOS violation"
}
if body.Description != nil && *body.Description != "" {
reason += ": " + *body.Description
}
if len(reason) > 256 {
reason = reason[:253] + "..."
}
err = db.UpdateObjectToTombstoneBySHA256Hash(sha256, &reason, true, body.QuarantineSample)
if err != nil {
log.Error().Err(err).Msg("failed to tombstone existing objects")
panic(apierrors.InternalServerError)
}
// Insert file ban
err = db.InsertFileBan(sha256, body.QuarantineSample, body.Reason, body.Description, body.MalwareName)
if err != nil {
log.Error().Err(err).Msg("failed to insert user into database")
panic(apierrors.InternalServerError)
}
// Return response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
render.JSON(w, r, banFileResponse{
Success: true,
StatusCode: nil,
SHA256: hex.EncodeToString(sha256),
})
}
package routes
import (
"database/sql"
"encoding/hex"
"net/http"
"strings"
"owo.codes/whats-this/api/lib/apierrors"
"owo.codes/whats-this/api/lib/db"
"owo.codes/whats-this/api/lib/middleware"
"github.com/go-chi/render"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
// bannedFileResponse is the response format for Object.
type bannedFileResponse struct {
Success bool `json:"success"`
Data db.FileBan `json:"data"`
}
// BannedFile returns metadata about a banned file to an administrator.
func BannedFile(w http.ResponseWriter, r *http.Request) {
// Only authorized admin users can use this route
user := middleware.GetAuthorizedUser(r)
if user.ID == "" || user.IsBlocked || !user.IsAdmin {
panic(apierrors.Unauthorized)
}
// Get the SHA256 hash
sha256String := r.URL.Path
if strings.HasPrefix(sha256String, "/bannedfiles/") {
sha256String = sha256String[13:]
}
sha256, err := hex.DecodeString(sha256String)
if err != nil {
panic(apierrors.BadFileID)
}
// Get the file ban
bannedFile, err := db.GetBannedFile(sha256)
switch {
case errors.Cause(err) == sql.ErrNoRows:
panic(apierrors.FileIsNotBanned)
case err != nil:
log.Error().Err(err).Msg("failed to get FileBan")
panic(apierrors.InternalServerError)
}
// Return response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
render.JSON(w, r, bannedFileResponse{true, bannedFile})
}
package routes
import (
"encoding/hex"
"net/http"
"strings"
"owo.codes/whats-this/api/lib/apierrors"
"owo.codes/whats-this/api/lib/db"
"owo.codes/whats-this/api/lib/middleware"
"github.com/rs/zerolog/log"
)
// DeleteBannedFile deletes a FileBan.
func DeleteBannedFile(w http.ResponseWriter, r *http.Request) {
// Only authorized admin users can use this route
user := middleware.GetAuthorizedUser(r)
if user.ID == "" || user.IsBlocked || !user.IsAdmin {
panic(apierrors.Unauthorized)
}
// Get the SHA256 hash
sha256String := r.URL.Path
if strings.HasPrefix(sha256String, "/bannedfiles/") {
sha256String = sha256String[13:]
}
sha256, err := hex.DecodeString(sha256String)
if err != nil {
panic(apierrors.BadFileID)
}
// Get the file ban
banned, err := db.CheckIfFileBanExists(sha256)
if err != nil {
log.Error().Err(err).Msg("failed to check if file is banned in DeleteBannedFile")
panic(apierrors.InternalServerError)
}
if !banned {
panic(apierrors.FileIsNotBanned)
}
// Unban the file
err = db.DeleteBannedFile(sha256)
if err != nil {
log.Error().Err(err).Msg("failed to delete file ban")
panic(apierrors.InternalServerError)
}
// Return success response
w.WriteHeader(http.StatusNoContent)
}
......@@ -11,6 +11,7 @@ import (
"owo.codes/whats-this/api/lib/apierrors"
"owo.codes/whats-this/api/lib/db"
"owo.codes/whats-this/api/lib/middleware"
"owo.codes/whats-this/api/lib/ratelimiter"
"github.com/go-chi/render"
"github.com/pkg/errors"
......@@ -30,6 +31,16 @@ func DeleteObject(w http.ResponseWriter, r *http.Request) {
panic(apierrors.Unauthorized)
}
// Apply ratelimits
bucket := middleware.GetBucket(r)
err := bucket.TakeWithHeaders(w, viper.GetInt64("ratelimiter.deleteObjectCost"))
if err == ratelimiter.InsufficientTokens {
panic(apierrors.InsufficientTokens)
}
if err != nil {
panic(apierrors.InternalServerError)
}
// Get the key
key := r.URL.Path
if strings.HasPrefix(key, "/objects/") {
......@@ -59,9 +70,11 @@ func DeleteObject(w http.ResponseWriter, r *http.Request) {
// Determine delete reason
deleteReason := defaultDeleteReason
deletedByAdmin := false
if user.IsAdmin {
if object.AssociatedUser != nil && *object.AssociatedUser != user.ID {
deleteReason = adminDeleteReason
deletedByAdmin = true
}
if delReason := r.URL.Query().Get("reason"); delReason != "" {
deleteReason = delReason
......@@ -69,7 +82,7 @@ func DeleteObject(w http.ResponseWriter, r *http.Request) {
}
// Mark as tombstone
err = db.UpdateObjectToTombstone(viper.GetString("database.objectBucket"), key, &deleteReason)
err = db.UpdateObjectToTombstoneByBucketKey(viper.GetString("database.objectBucket"), key, &deleteReason, deletedByAdmin, false)
if err != nil {
log.Error().Err(err).Msg("failed to set object to tombstone")
panic(apierrors.InternalServerError)
......@@ -77,12 +90,20 @@ func DeleteObject(w http.ResponseWriter, r *http.Request) {
// Delete file from disk if type was 0
if object.Type == 0 {
destPath := filepath.Join(viper.GetString("pomf.storageLocation"), object.Key)
err = os.Remove(destPath)
exists, err := db.CheckIfObjectExists(object.SHA256HashBytes)
if err != nil {
log.Error().Err(err).Msg("failed to delete object file from disk")
log.Error().Err(err).Str("hash", *object.SHA256Hash).Msg("failed to check if file exists")
panic(apierrors.InternalServerError)
}
if !exists {
destPath := filepath.Join(viper.GetString("files.storageLocation"), *object.SHA256Hash)
err = os.Remove(destPath)
if err != nil {
log.Error().Err(err).Msg("failed to delete object file from disk")
panic(apierrors.InternalServerError)
}
}
}
// Return tombstone response
......
package routes
import (
"fmt"
"net/http"
"owo.codes/whats-this/api/lib/apierrors"
"owo.codes/whats-this/api/lib/db"
"owo.codes/whats-this/api/lib/middleware"
"github.com/go-chi/render"
"github.com/rs/zerolog/log"
)
// listBannedFilesResponse is the response format for ListBannedFiles.
type listBannedFilesResponse struct {
Success bool `json:"success"`
Data []db.FileBan `json:"data"`
}
// ListBannedFiles returns a list of all banned files to an administrator.
func ListBannedFiles(w http.ResponseWriter, r *http.Request) {
// Only authorized admin users can use this route
user := middleware.GetAuthorizedUser(r)
if user.ID == "" || user.IsBlocked || !user.IsAdmin {
panic(apierrors.Unauthorized)
}
// Determine output format
format := r.URL.Query().Get("format")
if format != "json" && format != "text" {
format = "json"
}
// Get the data
bannedFiles, err := db.ListBannedFiles()
if err != nil {
log.Error().Err(err).Msg("failed to list banned files")
panic(apierrors.InternalServerError)
}
// Return response
switch format {
case "json":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
render.JSON(w, r, listBannedFilesResponse{true, bannedFiles})
case "text":
w.Header().Set("Content-Type", "text/plain; charset=utf8")
w.WriteHeader(http.StatusOK)
for _, bannedFile := range bannedFiles {
fmt.Fprint(w, bannedFile.SHA256Hash)
}
}
}