Skip to content

Commit

Permalink
Add instance admin web UI (#18)
Browse files Browse the repository at this point in the history
This implements an admin UI for instance maintainers to allow content
moderation as needed.

Basic initial features include:
- User lookup
- Listing files uploaded by a user
- File lookup
- File deletion
- User deletion

Admin declaration is handled with the YEETFILE_INSTANCE_ADMIN environment
variable, which can be set to an admin's email or account ID to enable.

Additional metadata was added to files uploaded via YF Send, since the same
need for moderation applies to those files as well. 

Currently this is web-only since instance maintainers are mostly using the web
interface from what I've gathered, but will still be ported to the CLI app
soon.
  • Loading branch information
benbusby authored Feb 4, 2025
1 parent 53a26ad commit d5ce571
Show file tree
Hide file tree
Showing 28 changed files with 753 additions and 33 deletions.
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Contents
1. [Self-Hosting](#self-hosting)
- [Access](#access)
- [Email Registration](#email-registration)
- [Administration](#administration)
- [Logging](#logging)
1. [CLI Configuration](#cli-configuration)
1. [Development](#development)
1. [Requirements](#requirements)
Expand Down Expand Up @@ -152,6 +154,48 @@ YEETFILE_EMAIL_USER=...
YEETFILE_EMAIL_PASSWORD=...
```

#### Administration

You can declare yourself as the admin of your instance by setting the
`YEETFILE_INSTANCE_ADMIN` environment variable to your YeetFile account ID or
email address.

This will allow you to manage users and their files on the instance. Note that
file names are encrypted, but you will be able to see the following metadata
for each file:

- File ID
- Last Modified
- Size
- Owner ID

#### Logging

Endpoints beginning with `/api/...` should be monitored for error codes to prevent bruteforcing.

For example:

- `/login` is the endpoint for the login web page, this only loads static content
- This will always return a `200` response, since there is nothing sensitive about loading
the login page.
- `/api/login` is the endpoint for submitting credentials
- This can return an error code depending on the failure (i.e. `403` for invalid credentials,
`404` for a non-existent user, etc)

You can limit requests to all `/api` endpoints in a Nginx config, for example, with something like
this:

```nginx
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/m;
// ...
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
proxy_pass http://backend;
}
```

## CLI Configuration

The YeetFile CLI tool can be configured using a `config.yml` file in the following path:
Expand Down Expand Up @@ -235,6 +279,9 @@ All environment variables can be defined in a file named `.env` at the root leve
| YEETFILE_CACHE_MAX_FILE_SIZE | The maximum file size to cache | 0 | An int value of bytes |
| YEETFILE_TLS_KEY | The SSL key to use for connections | | The string key contents (not a file path) |
| YEETFILE_TLS_CERT | The SSL cert to use for connections | | The string cert contents (not a file path) |
| YEETFILE_INSTANCE_ADMIN | The user ID or email of the user to set as admin | | A valid YeetFile email or account ID |
| YEETFILE_LIMITER_SECONDS | The number of seconds to use in rate limiting repeated requests | 30 | Any number of seconds |
| YEETFILE_LIMITER_ATTEMPTS | The number of attempts to allow before rate limiting | 6 | Any number of requests |
| YEETFILE_LOCKDOWN | Disables anonymous (not logged in) interactions | 0 | `1` to enable lockdown, `0` to allow anonymous usage |

#### Backblaze Environment Variables
Expand Down
8 changes: 8 additions & 0 deletions backend/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,17 @@ var secret = utils.GetEnvVarBytesB64("YEETFILE_SERVER_SECRET", defaultSecret)
var fallbackWebSecret = utils.GetEnvVarBytesB64(
"YEETFILE_FALLBACK_WEB_SECRET",
securecookie.GenerateRandomKey(32))
var limiterSeconds = utils.GetEnvVarInt("YEETFILE_LIMITER_SECONDS", 30)
var limiterAttempts = utils.GetEnvVarInt("YEETFILE_LIMITER_ATTEMPTS", 6)

var TLSCert = utils.GetEnvVar("YEETFILE_TLS_CERT", "")
var TLSKey = utils.GetEnvVar("YEETFILE_TLS_KEY", "")

var IsDebugMode = utils.GetEnvVarBool("YEETFILE_DEBUG", false)
var IsLockedDown = utils.GetEnvVarBool("YEETFILE_LOCKDOWN", false)

var InstanceAdmin = utils.GetEnvVar("YEETFILE_INSTANCE_ADMIN", "")

// =============================================================================
// Email configuration (used in account verification and billing reminders)
// =============================================================================
Expand Down Expand Up @@ -111,6 +115,8 @@ type ServerConfig struct {
PasswordHash []byte
ServerSecret []byte
FallbackWebSecret []byte
LimiterSeconds int
LimiterAttempts int
}

type TemplateConfig struct {
Expand Down Expand Up @@ -165,6 +171,8 @@ func init() {
PasswordHash: passwordHash,
ServerSecret: secret,
FallbackWebSecret: fallbackWebSecret,
LimiterSeconds: limiterSeconds,
LimiterAttempts: limiterAttempts,
}

// Subset of main server config to use in HTML templating
Expand Down
2 changes: 1 addition & 1 deletion backend/db/cron.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ var tasks = []CronTask{
{
Name: LimiterTask,
Interval: time.Second,
IntervalAmount: constants.LimiterSeconds,
IntervalAmount: config.YeetFileConfig.LimiterSeconds,
Enabled: true,
TaskFn: func() {}, // Set in InitCronTasks
},
Expand Down
6 changes: 2 additions & 4 deletions backend/db/expiry.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,12 @@ type FileExpiry struct {
Date time.Time
}

func SetFileExpiry(id string, downloads int, date time.Time) {
func SetFileExpiry(id string, downloads int, date time.Time) error {
s := `INSERT INTO expiry
(id, downloads, date)
VALUES ($1, $2, $3)`
_, err := db.Exec(s, id, downloads, date)
if err != nil {
panic(err)
}
return err
}

func DecrementDownloads(id string) int {
Expand Down
74 changes: 68 additions & 6 deletions backend/db/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ type FileMetadata struct {

// InsertMetadata creates a new metadata entry in the db and returns a unique ID for
// that entry.
func InsertMetadata(chunks int, name string, plaintext bool) (string, error) {
func InsertMetadata(chunks int, ownerID, name string, textOnly bool) (string, error) {
prefix := constants.FileIDPrefix
if plaintext {
if textOnly {
prefix = constants.PlaintextIDPrefix
}

Expand All @@ -43,9 +43,9 @@ func InsertMetadata(chunks int, name string, plaintext bool) (string, error) {
}

s := `INSERT INTO metadata
(id, chunks, filename, b2_id, length)
VALUES ($1, $2, $3, $4, $5)`
_, err := db.Exec(s, id, chunks, name, "", -1)
(id, chunks, filename, b2_id, length, owner_id, modified)
VALUES ($1, $2, $3, $4, $5, $6, $7)`
_, err := db.Exec(s, id, chunks, name, "", -1, ownerID, time.Now().UTC())
if err != nil {
panic(err)
}
Expand All @@ -70,7 +70,7 @@ func MetadataIDExists(id string) bool {
}

func RetrieveMetadata(id string) (FileMetadata, error) {
s := `SELECT m.*, e.downloads, e.date
s := `SELECT m.id, m.chunks, m.filename, m.b2_id, m.length, e.downloads, e.date
FROM metadata m
JOIN expiry e on m.id = e.id
WHERE m.id = $1`
Expand Down Expand Up @@ -146,3 +146,65 @@ func DeleteMetadata(id string) bool {

return true
}

func AdminRetrieveSendMetadata(fileID string) (shared.AdminFileInfoResponse, error) {
var (
id string
name string
length int64
ownerID string
modified time.Time
)

s := `SELECT id, filename, length, owner_id, modified FROM metadata WHERE id=$1`
err := db.QueryRow(s, fileID).Scan(&id, &name, &length, &ownerID, &modified)
return shared.AdminFileInfoResponse{
ID: id,
BucketName: name,
Size: shared.ReadableFileSize(length),
OwnerID: ownerID,
Modified: modified,

RawSize: length,
}, err
}

func AdminFetchSentFiles(userID string) ([]shared.AdminFileInfoResponse, error) {
result := []shared.AdminFileInfoResponse{}

s := `SELECT id, filename, length, owner_id, modified
FROM metadata
WHERE owner_id=$1`

rows, err := db.Query(s, userID)
if err != nil {
return result, err
}

defer rows.Close()
for rows.Next() {
var (
id string
filename string
length int64
ownerID string
modified time.Time
)

err = rows.Scan(&id, &filename, &length, &ownerID, &modified)
if err != nil {
return result, err
}

result = append(result, shared.AdminFileInfoResponse{
ID: id,
BucketName: filename,
Size: shared.ReadableFileSize(length),
OwnerID: ownerID,
Modified: modified,
RawSize: length,
})
}

return result, nil
}
5 changes: 5 additions & 0 deletions backend/db/scripts/migrations/4_send_metadata.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
ALTER TABLE metadata ADD COLUMN owner_id text DEFAULT '';
UPDATE metadata SET owner_id = '';

ALTER TABLE metadata ADD COLUMN modified TIMESTAMP DEFAULT now();
UPDATE metadata SET modified = now();
12 changes: 12 additions & 0 deletions backend/db/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,18 @@ func CheckUpgradeExpiration() {
}
}

func IsUserAdmin(id string) (bool, error) {
var isAdmin bool
s := `SELECT admin FROM users WHERE id=$1`
err := db.QueryRow(s, id).Scan(&isAdmin)

if err != nil {
return false, err
}

return isAdmin, nil
}

// ExpDateRollover checks to see if the user's upgrade expiration date takes
// place on a day that doesn't exist in other months. If so, the user's transfer
// limit should be upgraded "early". For example:
Expand Down
69 changes: 69 additions & 0 deletions backend/db/vault.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,53 @@ func DeleteVaultFile(id, ownerID string) error {
return err
}

// AdminDeleteFile deletes an entry in the file vault regardless of owner
func AdminDeleteFile(id string) error {
s := `DELETE FROM vault WHERE id=$1 OR ref_id=$1`
_, err := db.Exec(s, id)
return err
}

// AdminFetchVaultFiles fetches all files for a specific user
func AdminFetchVaultFiles(userID string) ([]shared.AdminFileInfoResponse, error) {
response := []shared.AdminFileInfoResponse{}

s := `SELECT id, name, length, owner_id, modified FROM vault WHERE owner_id=$1`
rows, err := db.Query(s, userID)
if err != nil {
log.Printf("Error retrieving files: %v\n", err)
return response, err
}

defer rows.Close()
for rows.Next() {
var (
id string
name string
length int64
ownerID string
modified time.Time
)

err = rows.Scan(&id, &name, &length, &ownerID, &modified)
if err != nil {
return response, err
}

response = append(response, shared.AdminFileInfoResponse{
ID: id,
BucketName: name,
Size: shared.ReadableFileSize(length),
OwnerID: ownerID,
Modified: modified,

RawSize: length,
})
}

return response, err
}

// DeleteSharedFile deletes a shared file from the recipient's vault
func DeleteSharedFile(id, ownerID string) error {
s := `DELETE FROM vault WHERE id=$1 AND owner_id=$2 RETURNING ref_id`
Expand Down Expand Up @@ -439,6 +486,28 @@ func RetrieveFullItemInfo(id, ownerID string) (shared.VaultItemInfo, error) {
}, nil
}

func AdminRetrieveMetadata(fileID string) (shared.AdminFileInfoResponse, error) {
var (
id string
name string
length int64
ownerID string
modified time.Time
)

s := `SELECT id, name, length, owner_id, modified FROM vault WHERE id=$1`
err := db.QueryRow(s, fileID).Scan(&id, &name, &length, &ownerID, &modified)
return shared.AdminFileInfoResponse{
ID: id,
BucketName: name,
Size: shared.ReadableFileSize(length),
OwnerID: ownerID,
Modified: modified,

RawSize: length,
}, err
}

// RetrieveVaultMetadata returns a FileMetadata struct containing a specific
// file's metadata
func RetrieveVaultMetadata(id, ownerID string) (FileMetadata, error) {
Expand Down
50 changes: 50 additions & 0 deletions backend/server/admin/file_actions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package admin

import (
"database/sql"
"log"
"yeetfile/backend/db"
"yeetfile/shared"
)

func deleteFile(fileID string) error {
metadata, err := db.AdminRetrieveMetadata(fileID)
if err == nil {
// Delete vault file
err = db.AdminDeleteFile(fileID)
if err != nil {
log.Printf("Error deleting file: %v\n", err)
return err
}

_ = db.UpdateStorageUsed(metadata.OwnerID, -metadata.RawSize)
} else {
// Attempt to delete Send file instead
metadata, err := db.RetrieveMetadata(fileID)
if err != nil {
return err
}

db.DeleteFileByMetadata(metadata)
}

return nil
}

func fetchFileMetadata(fileID string) (shared.AdminFileInfoResponse, error) {
fileInfo, err := db.AdminRetrieveMetadata(fileID)
if err != nil && err != sql.ErrNoRows {
return shared.AdminFileInfoResponse{}, err
} else if err == nil {
return fileInfo, nil
}

sendFileInfo, err := db.AdminRetrieveSendMetadata(fileID)
if err != nil && err != sql.ErrNoRows {
return shared.AdminFileInfoResponse{}, err
} else if err == nil {
return sendFileInfo, nil
}

return shared.AdminFileInfoResponse{}, err
}
Loading

0 comments on commit d5ce571

Please sign in to comment.