From fca0fed9723dc4e15d382c47c6c7121e32fa7399 Mon Sep 17 00:00:00 2001 From: Ben Busby Date: Mon, 3 Feb 2025 21:14:39 -0700 Subject: [PATCH 1/6] Validate upload URL before initiating large file upload There seems to be a situation where a large file upload can be marked as non-local and attempt to POST file contents to an invalid URL (in this case, the name of the local upload directory). This obviously results in an error. This change ensures that the designated upload URL is actually a valid URL, otherwise it forces the local-only "dummy" mode to be enabled. See #14 --- backend/server/transfer/upload.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/backend/server/transfer/upload.go b/backend/server/transfer/upload.go index edfba8e..07f6386 100644 --- a/backend/server/transfer/upload.go +++ b/backend/server/transfer/upload.go @@ -4,6 +4,7 @@ import ( "errors" "github.com/benbusby/b2" "log" + "net/url" db "yeetfile/backend/db" "yeetfile/backend/service" "yeetfile/backend/utils" @@ -155,12 +156,22 @@ func InitLargeB2Upload(filename string, upload db.B2Upload) error { return err } + isDummy := info.Dummy + if !isDummy { + // Ensure that the dummy option is enabled if the request URI + // is not actually valid + _, err = url.ParseRequestURI(upload.UploadURL) + if err != nil { + isDummy = true + } + } + return db.UpdateUploadValues( upload.MetadataID, info.UploadURL, info.AuthorizationToken, info.FileID, // Multi-chunk files use the file ID for uploading - info.Dummy) + isDummy) } func ResetLargeUpload(b2FileID string, metadataID string) (b2.FilePartInfo, error) { From 77ddd92c686f8cc98ce5eab8d595a76230415257 Mon Sep 17 00:00:00 2001 From: Ben Busby Date: Tue, 4 Feb 2025 12:32:44 -0700 Subject: [PATCH 2/6] Trim whitespace added by bubbletea on login w/ windows CLI Fixes #19 --- cli/commands/auth/login/login.go | 4 ++++ cross_compile.sh | 14 ++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/cli/commands/auth/login/login.go b/cli/commands/auth/login/login.go index e87952e..fcceb0a 100644 --- a/cli/commands/auth/login/login.go +++ b/cli/commands/auth/login/login.go @@ -1,6 +1,7 @@ package login import ( + "strings" "yeetfile/cli/crypto" "yeetfile/cli/globals" "yeetfile/cli/utils" @@ -11,6 +12,9 @@ import ( // generate the login key hash, and stores the user's key pair in their config // directory func LogIn(identifier, password, code string, sessionKey, vaultKey []byte) error { + identifier = strings.TrimSpace(identifier) + password = strings.TrimSpace(password) + userKey, loginKeyHash := crypto.GenerateUserKeys(identifier, password) login := shared.Login{ diff --git a/cross_compile.sh b/cross_compile.sh index feb64e7..8bfdf7c 100755 --- a/cross_compile.sh +++ b/cross_compile.sh @@ -67,10 +67,12 @@ do arch_name="arm32" fi - tar_name="${output_name}_${GOOS}_${arch_name}_${VER}.tar.gz" + compressed_name="${output_name}_${GOOS}_${arch_name}_${VER}.tar.gz" if [ $GOOS = "darwin" ]; then os_name="macOS" - tar_name="${output_name}_macos_${arch_name}_${VER}.tar.gz" + compressed_name="${output_name}_macos_${arch_name}_${VER}.tar.gz" + elif [ $GOOS = "windows" ]; then + compressed_name="${output_name}_windows_${arch_name}_${VER}.zip" fi if [ $GOOS = "windows" ]; then @@ -85,10 +87,14 @@ do exit 1 fi - tar -czvf out/$tar_name $output_name + if [ $GOOS = "windows" ]; then + zip -j out/$compressed_name $output_name + else + tar -czvf out/$compressed_name $output_name + fi rm -f $output_name - full_link="$RELEASE_NOTES_LINK/$tar_name" + full_link="$RELEASE_NOTES_LINK/$compressed_name" printf -- "- $os_name (\`$arch_name\`): [$tar_name]($full_link)\n" >> $RELEASE_NOTES_FILE done From af0b6a375e9c30aa18f51c94fd0215b2e88503cc Mon Sep 17 00:00:00 2001 From: Ben Busby Date: Tue, 4 Feb 2025 12:48:37 -0700 Subject: [PATCH 3/6] Include tld in email obfuscation --- backend/server/auth/handlers.go | 2 +- backend/server/html/handlers.go | 3 +-- backend/utils/misc.go | 31 ---------------------------- shared/utils.go | 36 +++++++++++++++++++++++++++++++++ 4 files changed, 38 insertions(+), 34 deletions(-) diff --git a/backend/server/auth/handlers.go b/backend/server/auth/handlers.go index 573c524..8455f52 100644 --- a/backend/server/auth/handlers.go +++ b/backend/server/auth/handlers.go @@ -161,7 +161,7 @@ func AccountHandler(w http.ResponseWriter, req *http.Request, id string) { return } - obscuredEmail, _ := utils.ObscureEmail(user.Email) + obscuredEmail, _ := shared.ObscureEmail(user.Email) _ = json.NewEncoder(w).Encode(shared.AccountResponse{ Email: obscuredEmail, PaymentID: user.PaymentID, diff --git a/backend/server/html/handlers.go b/backend/server/html/handlers.go index 28eff8c..ec0acb1 100644 --- a/backend/server/html/handlers.go +++ b/backend/server/html/handlers.go @@ -11,7 +11,6 @@ import ( "yeetfile/backend/server/html/templates" "yeetfile/backend/server/session" "yeetfile/backend/server/upgrades" - "yeetfile/backend/utils" "yeetfile/shared" "yeetfile/shared/endpoints" ) @@ -198,7 +197,7 @@ func AccountPageHandler(w http.ResponseWriter, req *http.Request, userID string) successMsg, errorMsg := generateAccountMessages(req) hasHint := user.PasswordHint != nil && len(user.PasswordHint) > 0 - obscuredEmail, _ := utils.ObscureEmail(user.Email) + obscuredEmail, _ := shared.ObscureEmail(user.Email) isPrevUpgraded := user.UpgradeExp.Year() >= 2024 _ = templates.ServeTemplate( diff --git a/backend/utils/misc.go b/backend/utils/misc.go index c1952c8..1d84a43 100644 --- a/backend/utils/misc.go +++ b/backend/utils/misc.go @@ -4,7 +4,6 @@ import ( "crypto/sha1" "encoding/base64" "encoding/json" - "errors" "fmt" "io" "log" @@ -238,36 +237,6 @@ func ParseSizeString(str string) int64 { return 0 } -// ObscureEmail takes an email and strips out the majority of the address and -// domain, adding "***" as an indicator of the obfuscation for both. -func ObscureEmail(email string) (string, error) { - segments := strings.Split(email, "@") - if len(segments) != 2 { - return "", errors.New("invalid email") - } - - address := segments[0] - domain := segments[1] - - var hiddenEmail string - if len(address) > 1 { - hiddenEmail = fmt.Sprintf( - "%c%c***%c@%c***.com", - address[0], - address[1], - address[len(address)-1], - domain[0]) - } else { - hiddenEmail = fmt.Sprintf( - "%c***%c@%c***.com", - address[0], - address[len(address)-1], - domain[0]) - } - - return hiddenEmail, nil -} - // LimitedChunkReader reads the request body, limited to max chunk size + encryption // overhead + 1024 bytes. This is big enough for all data-containing requests // made to the YeetFile API. diff --git a/shared/utils.go b/shared/utils.go index 0d016c9..ccbc8a2 100644 --- a/shared/utils.go +++ b/shared/utils.go @@ -2,6 +2,7 @@ package shared import ( "bufio" + "errors" "fmt" "math" "math/rand" @@ -184,3 +185,38 @@ func ArrayContains(items []string, target string) bool { } return false } + +// ObscureEmail takes an email and strips out the majority of the address and +// domain, adding "***" as an indicator of the obfuscation for both. +func ObscureEmail(email string) (string, error) { + segments := strings.Split(email, "@") + if len(segments) != 2 { + return "", errors.New("invalid email") + } + + address := segments[0] + domain := segments[1] + + segments = strings.Split(email, ".") + ext := segments[len(segments)-1] + + var hiddenEmail string + if len(address) > 1 { + hiddenEmail = fmt.Sprintf( + "%c%c***%c@%c***.%s", + address[0], + address[1], + address[len(address)-1], + domain[0], + ext) + } else { + hiddenEmail = fmt.Sprintf( + "%c***%c@%c***.%s", + address[0], + address[len(address)-1], + domain[0], + ext) + } + + return hiddenEmail, nil +} From 909c395b0a71ae0a56597941bece97b2b2648032 Mon Sep 17 00:00:00 2001 From: Ben Busby Date: Tue, 4 Feb 2025 12:58:33 -0700 Subject: [PATCH 4/6] Add issue/bug templates --- .github/ISSUE_TEMPLATE/bug_report.md | 25 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 10 +++++++++ 2 files changed, 35 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8599ffe --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,25 @@ +--- +name: Bug report +about: Report a bug in YeetFile +title: "[BUG] (describe bug)" +labels: bug +assignees: '' + +--- + +**Please check the box indicating where you encountered the bug:** + +- [ ] Self-hosted instance +- [ ] Official instance (yeetfile.com) + +**Describe the bug** + + + +**If self-hosted, please include non-private environment variables below** + +```sh +# Paste environment variables below these lines +# Do not include secret vars, like YEETFILE_DB_XXXX, YEETFILE_SECRET_KEY, etc + +``` diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..9071dbe --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,10 @@ +--- +name: Feature request +about: Suggest a new feature for YeetFile +title: "[FEATURE] (describe feature here)" +labels: enhancement +assignees: '' + +--- + +**Describe your requested feature below:** From 53a26ad063a3567cc227358671edb7836cc1b725 Mon Sep 17 00:00:00 2001 From: Ben Busby Date: Tue, 4 Feb 2025 13:46:58 -0700 Subject: [PATCH 5/6] Implement "lockdown" mode (#21) Lockdown mode prevents unauthenticated users form using any feature of YeetFile, including sending text content (which is normally available to any user). This can be enabled using `YEETFILE_LOCKDOWN=1` --- README.md | 5 +++-- backend/config/config.go | 1 + backend/server/html/handlers.go | 2 +- backend/server/middleware.go | 14 ++++++++++++++ backend/server/server.go | 6 +++--- backend/server/transfer/send/handlers.go | 2 +- 6 files changed, 23 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 296a276..d383a4c 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ If you need to access the web interface using a machine IP on your network, for generate a cert and set the `YEETFILE_TLS_CERT` and `YEETFILE_TLS_KEY` environment variables (see [Environment Variables](#environment-variables)) -> [!NOTE] +> [!NOTE] > This does not apply to the CLI tool. You can still use all features of YeetFile from the CLI tool > without a secure connection. @@ -173,7 +173,7 @@ default_view: "vault" # debug_file: "~/.config/yeetfile/debug.log" ``` -You can change the `server` directive to your own instance of YeetFile. +You can change the `server` directive to your own instance of YeetFile. ## Development @@ -235,6 +235,7 @@ 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_LOCKDOWN | Disables anonymous (not logged in) interactions | 0 | `1` to enable lockdown, `0` to allow anonymous usage | #### Backblaze Environment Variables diff --git a/backend/config/config.go b/backend/config/config.go index 2c46385..1ed4abc 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -37,6 +37,7 @@ 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) // ============================================================================= // Email configuration (used in account verification and billing reminders) diff --git a/backend/server/html/handlers.go b/backend/server/html/handlers.go index ec0acb1..8d80e37 100644 --- a/backend/server/html/handlers.go +++ b/backend/server/html/handlers.go @@ -86,7 +86,7 @@ func PassVaultPageHandler(w http.ResponseWriter, _ *http.Request, userID string) } // SendPageHandler returns the html template used for sending files -func SendPageHandler(w http.ResponseWriter, req *http.Request) { +func SendPageHandler(w http.ResponseWriter, req *http.Request, _ string) { var ( sendUsed int64 sendAvailable int64 diff --git a/backend/server/middleware.go b/backend/server/middleware.go index 0c543c6..bc3e1b0 100644 --- a/backend/server/middleware.go +++ b/backend/server/middleware.go @@ -74,6 +74,20 @@ func LimiterMiddleware(next http.HandlerFunc) http.HandlerFunc { return handler } +// LockdownAuthMiddleware conditionally prevents access to certain pages/actions +// if the instance is configured to be locked down. +func LockdownAuthMiddleware(next session.HandlerFunc) http.HandlerFunc { + if config.IsLockedDown { + return AuthMiddleware(next) + } + + handler := func(w http.ResponseWriter, req *http.Request) { + next(w, req, "") + } + + return handler +} + // AuthMiddleware enforces that a particular request has a valid session before // handling. func AuthMiddleware(next session.HandlerFunc) http.HandlerFunc { diff --git a/backend/server/server.go b/backend/server/server.go index 7fbd529..17ee924 100644 --- a/backend/server/server.go +++ b/backend/server/server.go @@ -50,7 +50,7 @@ func Run(host, port string) { // YeetFile Send {POST, endpoints.UploadSendFileMetadata, AuthMiddleware(send.UploadMetadataHandler)}, {POST, endpoints.UploadSendFileData, AuthMiddleware(send.UploadDataHandler)}, - {POST, endpoints.UploadSendText, LimiterMiddleware(send.UploadPlaintextHandler)}, + {POST, endpoints.UploadSendText, LimiterMiddleware(LockdownAuthMiddleware(send.UploadPlaintextHandler))}, {GET, endpoints.DownloadSendFileMetadata, send.DownloadHandler}, {GET, endpoints.DownloadSendFileData, send.DownloadChunkHandler}, @@ -94,8 +94,8 @@ func Run(host, port string) { {GET, endpoints.BTCPayCheckout, BTCPayMiddleware(AuthMiddleware(payments.BTCPayCheckout))}, // HTML - {GET, endpoints.HTMLHome, html.SendPageHandler}, - {GET, endpoints.HTMLSend, html.SendPageHandler}, + {GET, endpoints.HTMLHome, LockdownAuthMiddleware(html.SendPageHandler)}, + {GET, endpoints.HTMLSend, LockdownAuthMiddleware(html.SendPageHandler)}, {GET, endpoints.HTMLPass, AuthMiddleware(html.PassVaultPageHandler)}, {GET, endpoints.HTMLPassFolder, AuthMiddleware(html.PassVaultPageHandler)}, {GET, endpoints.HTMLPassEntry, AuthMiddleware(html.PassVaultPageHandler)}, diff --git a/backend/server/transfer/send/handlers.go b/backend/server/transfer/send/handlers.go index 17aa773..ad58e6a 100644 --- a/backend/server/transfer/send/handlers.go +++ b/backend/server/transfer/send/handlers.go @@ -129,7 +129,7 @@ func UploadDataHandler(w http.ResponseWriter, req *http.Request, userID string) // UploadPlaintextHandler handles uploading plaintext with a max size of // shared.MaxPlaintextLen characters (constants.go). -func UploadPlaintextHandler(w http.ResponseWriter, req *http.Request) { +func UploadPlaintextHandler(w http.ResponseWriter, req *http.Request, _ string) { var plaintextUpload shared.PlaintextUpload err := utils.LimitedJSONReader(w, req.Body).Decode(&plaintextUpload) if err != nil { From d5ce571dd7335ddfc7064633ee57252b727037df Mon Sep 17 00:00:00 2001 From: Ben Busby Date: Tue, 4 Feb 2025 14:05:17 -0700 Subject: [PATCH 6/6] Add instance admin web UI (#18) 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. --- README.md | 47 +++++ backend/config/config.go | 8 + backend/db/cron.go | 2 +- backend/db/expiry.go | 6 +- backend/db/metadata.go | 74 +++++++- .../db/scripts/migrations/4_send_metadata.sql | 5 + backend/db/users.go | 12 ++ backend/db/vault.go | 69 +++++++ backend/server/admin/file_actions.go | 50 +++++ backend/server/admin/handlers.go | 81 ++++++++ backend/server/admin/user_actions.go | 52 +++++ backend/server/auth/auth.go | 16 +- backend/server/auth/handlers.go | 5 +- backend/server/html/handlers.go | 21 +++ backend/server/html/templates/account.html | 5 + backend/server/html/templates/admin.html | 26 +++ backend/server/html/templates/templates.go | 6 + backend/server/middleware.go | 35 +++- backend/server/server.go | 6 + backend/server/transfer/send/handlers.go | 25 ++- backend/static/css/admin.css | 8 + backend/static/css/main.css | 7 + cli/api/vault_test.go | 4 +- shared/constants/constants.go | 5 - shared/endpoints/endpoints.go | 10 + shared/structs.go | 19 ++ utils/generate_typescript.go | 4 +- web/ts/admin.ts | 178 ++++++++++++++++++ 28 files changed, 753 insertions(+), 33 deletions(-) create mode 100644 backend/db/scripts/migrations/4_send_metadata.sql create mode 100644 backend/server/admin/file_actions.go create mode 100644 backend/server/admin/handlers.go create mode 100644 backend/server/admin/user_actions.go create mode 100644 backend/server/html/templates/admin.html create mode 100644 backend/static/css/admin.css create mode 100644 web/ts/admin.ts diff --git a/README.md b/README.md index d383a4c..a74a419 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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: @@ -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 diff --git a/backend/config/config.go b/backend/config/config.go index 1ed4abc..298ffa9 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -32,6 +32,8 @@ 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", "") @@ -39,6 +41,8 @@ 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) // ============================================================================= @@ -111,6 +115,8 @@ type ServerConfig struct { PasswordHash []byte ServerSecret []byte FallbackWebSecret []byte + LimiterSeconds int + LimiterAttempts int } type TemplateConfig struct { @@ -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 diff --git a/backend/db/cron.go b/backend/db/cron.go index f371734..8676c90 100644 --- a/backend/db/cron.go +++ b/backend/db/cron.go @@ -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 }, diff --git a/backend/db/expiry.go b/backend/db/expiry.go index 509d4da..284cea1 100644 --- a/backend/db/expiry.go +++ b/backend/db/expiry.go @@ -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 { diff --git a/backend/db/metadata.go b/backend/db/metadata.go index 0332c28..7c2b1ac 100644 --- a/backend/db/metadata.go +++ b/backend/db/metadata.go @@ -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 } @@ -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) } @@ -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` @@ -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 +} diff --git a/backend/db/scripts/migrations/4_send_metadata.sql b/backend/db/scripts/migrations/4_send_metadata.sql new file mode 100644 index 0000000..ad88e3b --- /dev/null +++ b/backend/db/scripts/migrations/4_send_metadata.sql @@ -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(); \ No newline at end of file diff --git a/backend/db/users.go b/backend/db/users.go index 15bd494..0e730bf 100644 --- a/backend/db/users.go +++ b/backend/db/users.go @@ -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: diff --git a/backend/db/vault.go b/backend/db/vault.go index e4f02ea..783a83b 100644 --- a/backend/db/vault.go +++ b/backend/db/vault.go @@ -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` @@ -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) { diff --git a/backend/server/admin/file_actions.go b/backend/server/admin/file_actions.go new file mode 100644 index 0000000..29e9492 --- /dev/null +++ b/backend/server/admin/file_actions.go @@ -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 +} diff --git a/backend/server/admin/handlers.go b/backend/server/admin/handlers.go new file mode 100644 index 0000000..c7847f8 --- /dev/null +++ b/backend/server/admin/handlers.go @@ -0,0 +1,81 @@ +package admin + +import ( + "database/sql" + "encoding/json" + "log" + "net/http" + "strings" + "yeetfile/shared" +) + +func UserActionHandler(w http.ResponseWriter, req *http.Request, id string) { + segments := strings.Split(req.URL.Path, "/") + userID := segments[len(segments)-1] + + if userID == id { + http.Error(w, "Cannot fetch yourself", http.StatusBadRequest) + return + } + + switch req.Method { + case http.MethodDelete: + err := deleteUser(userID) + if err != nil { + log.Printf("Error deleting user: %v\n", err) + http.Error(w, "Failed to delete user", http.StatusInternalServerError) + return + } + case http.MethodGet: + user, err := getUserInfo(userID) + if err != nil { + log.Printf("Error fetching user: %v\n", err) + if err == sql.ErrNoRows { + http.Error(w, "No match found", http.StatusNotFound) + return + } + + http.Error(w, "Failed to fetch user info", http.StatusInternalServerError) + return + } + + files := fetchAllFiles(userID) + userResponse := shared.AdminUserInfoResponse{ + ID: user.ID, + Email: user.Email, + StorageUsed: shared.ReadableFileSize(user.StorageUsed), + SendUsed: shared.ReadableFileSize(user.SendUsed), + + Files: files, + } + + _ = json.NewEncoder(w).Encode(userResponse) + } +} + +func FileActionHandler(w http.ResponseWriter, req *http.Request, _ string) { + segments := strings.Split(req.URL.Path, "/") + fileID := segments[len(segments)-1] + + switch req.Method { + case http.MethodDelete: + err := deleteFile(fileID) + if err != nil { + http.Error(w, "Error deleting file", http.StatusInternalServerError) + return + } + case http.MethodGet: + fileInfo, err := fetchFileMetadata(fileID) + if err == sql.ErrNoRows { + http.Error(w, "No match found", http.StatusNotFound) + return + } else if err != nil { + log.Printf("Error fetching file metadata: %v\n", err) + http.Error(w, "Error fetching file metadata", http.StatusInternalServerError) + return + } + + _ = json.NewEncoder(w).Encode(fileInfo) + } + +} diff --git a/backend/server/admin/user_actions.go b/backend/server/admin/user_actions.go new file mode 100644 index 0000000..8fa5a44 --- /dev/null +++ b/backend/server/admin/user_actions.go @@ -0,0 +1,52 @@ +package admin + +import ( + "log" + "strings" + "yeetfile/backend/db" + "yeetfile/backend/server/auth" + "yeetfile/shared" +) + +func deleteUser(userID string) error { + return auth.DeleteUser(userID, shared.DeleteAccount{Identifier: userID}) +} + +func fetchAllFiles(userID string) []shared.AdminFileInfoResponse { + if strings.Contains(userID, "@") { + userID, _ = db.GetUserIDByEmail(userID) + } + + files := []shared.AdminFileInfoResponse{} + vaultFiles, err := db.AdminFetchVaultFiles(userID) + if err != nil { + log.Printf("Error fetching user files: %v\n", err) + } + + files = append(files, vaultFiles...) + + sendFiles, err := db.AdminFetchSentFiles(userID) + if err != nil { + log.Printf("Error fetching user send files: %v\n", err) + } + + files = append(files, sendFiles...) + return files +} + +func getUserInfo(userID string) (db.User, error) { + var err error + if strings.Contains(userID, "@") { + userID, err = db.GetUserIDByEmail(userID) + if err != nil { + return db.User{}, err + } + } + + user, err := db.GetUserByID(userID) + if err != nil { + return db.User{}, err + } + + return user, nil +} diff --git a/backend/server/auth/auth.go b/backend/server/auth/auth.go index 9043c6d..f352260 100644 --- a/backend/server/auth/auth.go +++ b/backend/server/auth/auth.go @@ -5,6 +5,7 @@ import ( "golang.org/x/crypto/bcrypt" "log" "strings" + "yeetfile/backend/config" "yeetfile/backend/db" "yeetfile/backend/server/transfer/vault" "yeetfile/shared" @@ -120,7 +121,20 @@ func updateUser(values db.VerifiedAccountValues) error { return nil } -func deleteUser(id string, deleteAccount shared.DeleteAccount) error { +func IsInstanceAdmin(currentUserID string) bool { + adminID := config.InstanceAdmin + if len(adminID) > 0 { + if strings.Contains(adminID, "@") { + adminID, _ = db.GetUserIDByEmail(adminID) + } + + return adminID == currentUserID + } + + return false +} + +func DeleteUser(id string, deleteAccount shared.DeleteAccount) error { accountID := deleteAccount.Identifier var err error if strings.Contains(deleteAccount.Identifier, "@") { diff --git a/backend/server/auth/handlers.go b/backend/server/auth/handlers.go index 8455f52..d75734f 100644 --- a/backend/server/auth/handlers.go +++ b/backend/server/auth/handlers.go @@ -20,6 +20,7 @@ import ( func LoginHandler(w http.ResponseWriter, req *http.Request) { var login shared.Login if utils.LimitedJSONReader(w, req.Body).Decode(&login) != nil { + log.Printf("Error decoding login request") w.WriteHeader(http.StatusBadRequest) return } @@ -27,9 +28,11 @@ func LoginHandler(w http.ResponseWriter, req *http.Request) { userID, err := ValidateCredentials(login.Identifier, login.LoginKeyHash, login.Code, true) if err != nil { if err == Missing2FAErr { + log.Printf("Error: Missing TOTP") http.Error(w, "TOTP required", http.StatusForbidden) return } else if err == Failed2FAErr { + log.Printf("Error: Incorrect TOTP") http.Error(w, "TOTP incorrect", http.StatusForbidden) return } @@ -143,7 +146,7 @@ func AccountHandler(w http.ResponseWriter, req *http.Request, id string) { return } - err := deleteUser(id, deleteAccount) + err := DeleteUser(id, deleteAccount) if err != nil { http.Error(w, "Error deleting account", http.StatusBadRequest) return diff --git a/backend/server/html/handlers.go b/backend/server/html/handlers.go index 8d80e37..80d9758 100644 --- a/backend/server/html/handlers.go +++ b/backend/server/html/handlers.go @@ -8,6 +8,7 @@ import ( "time" "yeetfile/backend/config" "yeetfile/backend/db" + "yeetfile/backend/server/auth" "yeetfile/backend/server/html/templates" "yeetfile/backend/server/session" "yeetfile/backend/server/upgrades" @@ -200,6 +201,8 @@ func AccountPageHandler(w http.ResponseWriter, req *http.Request, userID string) obscuredEmail, _ := shared.ObscureEmail(user.Email) isPrevUpgraded := user.UpgradeExp.Year() >= 2024 + isAdmin := auth.IsInstanceAdmin(userID) + _ = templates.ServeTemplate( w, templates.AccountHTML, @@ -227,6 +230,7 @@ func AccountPageHandler(w http.ResponseWriter, req *http.Request, userID string) Has2FA: user.Secret != nil && len(user.Secret) > 0, ErrorMessage: errorMsg, SuccessMessage: successMsg, + IsAdmin: isAdmin, }, ) } @@ -476,6 +480,23 @@ func CheckoutCompleteHandler(w http.ResponseWriter, req *http.Request) { ) } +func AdminPageHandler(w http.ResponseWriter, _ *http.Request, id string) { + _ = templates.ServeTemplate( + w, + templates.AdminHTML, + templates.AdminTemplate{ + Base: templates.BaseTemplate{ + LoggedIn: true, + Title: "Admin", + Javascript: []string{"admin.js"}, + CSS: []string{"admin.css"}, + Config: config.HTMLConfig, + Endpoints: endpoints.HTMLPageEndpoints, + }, + }, + ) +} + // generateAccountMessages takes a request and generates success and error messages from // the data contained in the request. func generateAccountMessages(req *http.Request) (string, string) { diff --git a/backend/server/html/templates/account.html b/backend/server/html/templates/account.html index 701b340..33a8f87 100644 --- a/backend/server/html/templates/account.html +++ b/backend/server/html/templates/account.html @@ -70,6 +70,11 @@

Account

{{ end }} + {{ if .IsAdmin }} + + +
+ {{ end }} {{ if or .Base.Config.StripeEnabled .Base.Config.BTCPayEnabled }} diff --git a/backend/server/html/templates/admin.html b/backend/server/html/templates/admin.html new file mode 100644 index 0000000..b7fc7f0 --- /dev/null +++ b/backend/server/html/templates/admin.html @@ -0,0 +1,26 @@ +{{ template "head.html" . }} + +{{ template "header.html" . }} +
+

Admin

+
+ +

User Search

+ +
+
+
+
+ +
+ +

File Search

+ +
+ +
+
+ +
+{{ template "footer.html" . }} + \ No newline at end of file diff --git a/backend/server/html/templates/templates.go b/backend/server/html/templates/templates.go index b9c495a..e93f0f9 100644 --- a/backend/server/html/templates/templates.go +++ b/backend/server/html/templates/templates.go @@ -27,6 +27,7 @@ const ( TwoFactorHTML = "enable_2fa.html" ServerInfoHTML = "server_info.html" CheckoutCompleteHTML = "checkout_complete.html" + AdminHTML = "admin.html" ) //go:embed *.html @@ -101,6 +102,10 @@ type CheckoutCompleteTemplate struct { Note string } +type AdminTemplate struct { + Base BaseTemplate +} + type AccountTemplate struct { Base BaseTemplate Email string @@ -121,6 +126,7 @@ type AccountTemplate struct { Has2FA bool ErrorMessage string SuccessMessage string + IsAdmin bool } type UpgradeTemplate struct { diff --git a/backend/server/middleware.go b/backend/server/middleware.go index bc3e1b0..7e3730e 100644 --- a/backend/server/middleware.go +++ b/backend/server/middleware.go @@ -8,9 +8,9 @@ import ( "sync" "time" "yeetfile/backend/config" + "yeetfile/backend/server/auth" "yeetfile/backend/server/session" "yeetfile/backend/utils" - "yeetfile/shared/constants" "yeetfile/shared/endpoints" ) @@ -39,8 +39,8 @@ func getVisitor(identifier string, path string) *rate.Limiter { idHash := blake2b.Sum256([]byte(identifier + path)) visitor, exists := visitors[idHash] if !exists { - limit := rate.Every(time.Second * constants.LimiterSeconds) - limiter := rate.NewLimiter(limit, constants.LimiterAttempts) + limit := rate.Every(time.Second * time.Duration(config.YeetFileConfig.LimiterSeconds)) + limiter := rate.NewLimiter(limit, config.YeetFileConfig.LimiterAttempts) visitors[idHash] = &Visitor{limiter, time.Now()} return limiter } @@ -113,10 +113,31 @@ func AuthMiddleware(next session.HandlerFunc) http.HandlerFunc { `, loginURL) w.Write([]byte(redirect)) + return + } + + return handler +} + +// AdminMiddleware enforces that particular requests are only performed by those +// marked as "admin" in the database. +func AdminMiddleware(next session.HandlerFunc) http.HandlerFunc { + handler := func(w http.ResponseWriter, req *http.Request) { + if session.IsValidSession(w, req) { + id, err := session.GetSessionAndUserID(req) + if err != nil { + return + } + + isAdmin := auth.IsInstanceAdmin(id) + if isAdmin { + // Call the next handler + next(w, req, id) + return + } + } - //redirectURL := fmt.Sprintf("%s?next=%s", endpoints.HTMLLogin, req.URL.Path) - //redirectCode := http.StatusTemporaryRedirect - //http.Redirect(w, req, redirectURL, redirectCode) + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } @@ -166,7 +187,7 @@ func DefaultHeadersMiddleware(next http.HandlerFunc) http.HandlerFunc { } // AuthLimiterMiddleware is like AuthMiddleware, but also restricts requests to -// the same constants.LimiterAttempts per constants.LimiterSeconds by session +// the same config.LimiterAttempts per config.LimiterSeconds by session // (unlike LimiterMiddleware which limits by IP address) func AuthLimiterMiddleware(next session.HandlerFunc) http.HandlerFunc { handler := func(w http.ResponseWriter, req *http.Request) { diff --git a/backend/server/server.go b/backend/server/server.go index 17ee924..f43d2a5 100644 --- a/backend/server/server.go +++ b/backend/server/server.go @@ -11,6 +11,7 @@ import ( "strings" "syscall" "yeetfile/backend/config" + "yeetfile/backend/server/admin" "yeetfile/backend/server/auth" "yeetfile/backend/server/html" "yeetfile/backend/server/misc" @@ -87,6 +88,10 @@ func Run(host, port string) { {POST, endpoints.ChangeHint, AuthMiddleware(auth.ChangeHintHandler)}, {PUT, endpoints.RecyclePaymentID, AuthMiddleware(auth.RecyclePaymentIDHandler)}, + // Admin + {GET | DELETE, endpoints.AdminUserActions, AdminMiddleware(admin.UserActionHandler)}, + {GET | DELETE, endpoints.AdminFileActions, AdminMiddleware(admin.FileActionHandler)}, + // Payments (Stripe, BTCPay) {POST, endpoints.StripeWebhook, payments.StripeWebhook}, {GET, endpoints.StripeCheckout, StripeMiddleware(AuthMiddleware(payments.StripeCheckout))}, @@ -115,6 +120,7 @@ func Run(host, port string) { {GET, endpoints.HTMLTwoFactor, AuthMiddleware(html.TwoFactorPageHandler)}, {GET, endpoints.HTMLServerInfo, html.ServerInfoPageHandler}, {GET, endpoints.HTMLCheckoutComplete, html.CheckoutCompleteHandler}, + {GET, endpoints.HTMLAdmin, AdminMiddleware(html.AdminPageHandler)}, // Misc { // Static folder files diff --git a/backend/server/transfer/send/handlers.go b/backend/server/transfer/send/handlers.go index ad58e6a..c0a95cc 100644 --- a/backend/server/transfer/send/handlers.go +++ b/backend/server/transfer/send/handlers.go @@ -20,7 +20,7 @@ import ( // UploadMetadataHandler handles a POST request to /u with the metadata required to set // up a file for uploading. This is defined in the UploadMetadata struct. -func UploadMetadataHandler(w http.ResponseWriter, req *http.Request, _ string) { +func UploadMetadataHandler(w http.ResponseWriter, req *http.Request, userID string) { var meta shared.UploadMetadata data, _ := utils.LimitedReader(w, req.Body) err := json.Unmarshal(data, &meta) @@ -48,11 +48,16 @@ func UploadMetadataHandler(w http.ResponseWriter, req *http.Request, _ string) { return } - id, _ := db.InsertMetadata(meta.Chunks, meta.Name, false) + id, _ := db.InsertMetadata(meta.Chunks, userID, meta.Name, false) b2Upload := db.CreateNewUpload(id, meta.Name) exp := utils.StrToDuration(meta.Expiration, config.IsDebugMode) - db.SetFileExpiry(id, meta.Downloads, time.Now().Add(exp).UTC()) + err = db.SetFileExpiry(id, meta.Downloads, time.Now().Add(exp).UTC()) + if err != nil { + log.Printf("Error setting file expiry: %v\n", err) + http.Error(w, "Server error", http.StatusInternalServerError) + return + } var b2Err error if meta.Chunks == 1 { @@ -143,11 +148,21 @@ func UploadPlaintextHandler(w http.ResponseWriter, req *http.Request, _ string) return } - id, _ := db.InsertMetadata(1, plaintextUpload.Name, true) + id, err := db.InsertMetadata(1, "", plaintextUpload.Name, true) + if err != nil { + log.Printf("Error inserting new text-only upload metadata: %v\n", err) + http.Error(w, "Unable to init metadata", http.StatusInternalServerError) + return + } b2Upload := db.CreateNewUpload(id, plaintextUpload.Name) exp := utils.StrToDuration(plaintextUpload.Expiration, config.IsDebugMode) - db.SetFileExpiry(id, plaintextUpload.Downloads, time.Now().Add(exp).UTC()) + err = db.SetFileExpiry(id, plaintextUpload.Downloads, time.Now().UTC().Add(exp)) + if err != nil { + log.Printf("Error setting file expiry: %v\n", err) + http.Error(w, "Server error", http.StatusInternalServerError) + return + } err = transfer.InitB2Upload(b2Upload) if err != nil { diff --git a/backend/static/css/admin.css b/backend/static/css/admin.css new file mode 100644 index 0000000..b915e61 --- /dev/null +++ b/backend/static/css/admin.css @@ -0,0 +1,8 @@ +code { + word-wrap: anywhere; +} + +.span-header { + display: block; + margin-top: 10px; +} \ No newline at end of file diff --git a/backend/static/css/main.css b/backend/static/css/main.css index e9360eb..72d96e1 100644 --- a/backend/static/css/main.css +++ b/backend/static/css/main.css @@ -69,6 +69,13 @@ fieldset { display: initial; } +.bordered-box { + border: 1px solid var(--secondary-text-color); + border-radius: 5px; + padding: 10px; + margin-top: 10px; +} + .accent-hr { color: var(--accent-color) !important; background-color: var(--accent-color) !important; diff --git a/cli/api/vault_test.go b/cli/api/vault_test.go index f6770a5..c2579d8 100644 --- a/cli/api/vault_test.go +++ b/cli/api/vault_test.go @@ -11,9 +11,9 @@ import ( "net/http" "strings" "testing" + "yeetfile/backend/config" "yeetfile/cli/crypto" "yeetfile/shared" - "yeetfile/shared/constants" "yeetfile/shared/endpoints" ) @@ -353,7 +353,7 @@ func TestDownloadLimiter(t *testing.T) { } attempt := 1 - for attempt <= constants.LimiterAttempts { + for attempt <= config.YeetFileConfig.LimiterAttempts { err = downloadFunc() assert.Nil(t, err) attempt += 1 diff --git a/shared/constants/constants.go b/shared/constants/constants.go index 7c2c740..ebb6274 100644 --- a/shared/constants/constants.go +++ b/shared/constants/constants.go @@ -15,8 +15,6 @@ const ( AuthSessionStore = "auth" Argon2Mem uint32 = 64 // MB Argon2Iter uint32 = 2 - LimiterSeconds = 30 - LimiterAttempts = 6 TotalBandwidthMultiplier = 3 // 3x available storage BandwidthMonitorDuration = 7 // 7 day period IVSize = 12 @@ -33,7 +31,4 @@ const ( MaxSendAgeDays = 30 //days MaxPassNoteLen = 500 RecoveryCodeLen = 8 - - DurationMonth UpgradeDuration = "month" - DurationYear UpgradeDuration = "year" ) diff --git a/shared/endpoints/endpoints.go b/shared/endpoints/endpoints.go index e26f485..fd7fa23 100644 --- a/shared/endpoints/endpoints.go +++ b/shared/endpoints/endpoints.go @@ -22,6 +22,7 @@ type HTMLEndpoints struct { VaultFile string Info string Upgrade string + Admin string } type BillingEndpoints struct { @@ -49,6 +50,9 @@ var ( ChangeHint = Endpoint("/api/change/hint") ServerInfo = Endpoint("/api/info") + AdminUserActions = Endpoint("/api/admin/user/*") + AdminFileActions = Endpoint("/api/admin/files/*") + Up = Endpoint("/up") PassRoot = Endpoint("/api/pass") @@ -105,6 +109,7 @@ var ( HTMLServerInfo = Endpoint("/info") HTMLCheckoutComplete = Endpoint("/checkout/complete") HTMLUpgrade = Endpoint("/upgrade") + HTMLAdmin = Endpoint("/admin") ) var JSVarNameMap = map[Endpoint]string{ @@ -124,6 +129,9 @@ var JSVarNameMap = map[Endpoint]string{ ChangeHint: "ChangeHint", ServerInfo: "ServerInfo", + AdminUserActions: "AdminUserActions", + AdminFileActions: "AdminFileActions", + PassRoot: "PassRoot", PassFolder: "PassFolder", PassEntry: "PassEntry", @@ -172,6 +180,7 @@ var JSVarNameMap = map[Endpoint]string{ HTMLTwoFactor: "HTMLTwoFactor", HTMLServerInfo: "HTMLServerInfo", HTMLCheckoutComplete: "HTMLCheckoutComplete", + HTMLAdmin: "HTMLAdmin", } func (e Endpoint) Format(server string, args ...string) string { @@ -205,6 +214,7 @@ func init() { TwoFactor: string(HTMLTwoFactor), Info: string(HTMLServerInfo), Upgrade: string(HTMLUpgrade), + Admin: string(HTMLAdmin), } BillingPageEndpoints = BillingEndpoints{ diff --git a/shared/structs.go b/shared/structs.go index d1352eb..f109e39 100644 --- a/shared/structs.go +++ b/shared/structs.go @@ -353,3 +353,22 @@ type Upgrades struct { SendUpgrades []*Upgrade `json:"send_upgrades"` VaultUpgrades []*Upgrade `json:"vault_upgrades"` } + +type AdminUserInfoResponse struct { + ID string `json:"id"` + Email string `json:"email"` + StorageUsed string `json:"storageUsed"` + SendUsed string `json:"sendUsed"` + + Files []AdminFileInfoResponse `json:"files"` +} + +type AdminFileInfoResponse struct { + ID string `json:"id"` + BucketName string `json:"bucketName"` + Size string `json:"size"` + OwnerID string `json:"ownerID"` + Modified time.Time `json:"modified" ts_type:"Date" ts_transform:"new Date(__VALUE__)"` + + RawSize int64 +} diff --git a/utils/generate_typescript.go b/utils/generate_typescript.go index e33bc77..966f799 100644 --- a/utils/generate_typescript.go +++ b/utils/generate_typescript.go @@ -89,7 +89,9 @@ func main() { Add(shared.NewTOTP{}). Add(shared.SetTOTP{}). Add(shared.SetTOTPResponse{}). - Add(shared.ItemIndex{}) + Add(shared.ItemIndex{}). + Add(shared.AdminUserInfoResponse{}). + Add(shared.AdminFileInfoResponse{}) converter.WithBackupDir("") err = converter.ConvertToFile(structsOut) diff --git a/web/ts/admin.ts b/web/ts/admin.ts new file mode 100644 index 0000000..9162e50 --- /dev/null +++ b/web/ts/admin.ts @@ -0,0 +1,178 @@ +import {Endpoints} from "./endpoints.js"; +import {AdminFileInfoResponse, AdminUserInfoResponse} from "./interfaces.js"; + +const init = () => { + setupUserSearch(); + setupFileSearch(); +} + +// ============================================================================= +// User admin +// ============================================================================= + +const setupUserSearch = () => { + let userSearchBtn = document.getElementById("user-search-btn") as HTMLButtonElement; + let userIDInput = document.getElementById("user-id") as HTMLInputElement; + + userSearchBtn.addEventListener("click", () => { + let userID = userIDInput.value; + fetch(Endpoints.format(Endpoints.AdminUserActions, userID)).then(async response => { + if (!response.ok) { + alert("Error fetching user: " + await response.text()); + return; + } + + let responseDiv = document.getElementById("user-response"); + responseDiv.innerHTML = ""; + + let userInfo = new AdminUserInfoResponse(await response.json()); + let userDiv = generateUserActionsHTML(userInfo); + responseDiv.appendChild(userDiv); + }).catch((error: Error) => { + alert("Error fetching user"); + console.error(error); + }) + }); +} + +const generateUserActionsHTML = (userInfo: AdminUserInfoResponse): HTMLDivElement => { + let userResponseDiv = document.createElement("div") as HTMLDivElement; + + userResponseDiv.className = "bordered-box visible"; + + let userInfoElement = document.createElement("code"); + userInfoElement.innerText = `ID: ${userInfo.id} +Email: ${userInfo.email} +Storage Used: ${userInfo.storageUsed} +Send Used: ${userInfo.sendUsed}`; + + userResponseDiv.appendChild(userInfoElement); + userResponseDiv.appendChild(document.createElement("br")); + + let deleteBtnID = `delete-user-${userInfo.id}`; + let deleteButton = document.createElement("button"); + deleteButton.id = deleteBtnID; + deleteButton.className = "red-button"; + deleteButton.innerText = "Delete User and Uploads"; + + userResponseDiv.appendChild(deleteButton); + + deleteButton.addEventListener("click", () => { + if (!confirm("Deleting this user will also delete all files they have " + + "uploaded. Do you wish to proceed?")) { + return; + } + + fetch(Endpoints.format(Endpoints.AdminUserActions, userInfo.id), { + method: "DELETE" + }).then(async response => { + if (!response.ok) { + alert("Failed to delete user! " + await response.text()); + } else { + alert("User and their content has been deleted!"); + userResponseDiv.innerHTML = ""; + userResponseDiv.className = "hidden"; + } + }).catch(error => { + alert("Failed to delete user"); + console.error(error); + }); + }); + + if (userInfo.files.length > 0) { + let header = document.createElement("span"); + header.className = "span-header"; + header.innerText = "Files:" + userResponseDiv.appendChild(header); + } + + for (let i = 0; i < userInfo.files.length; i++) { + let fileDiv = generateFileActionsHTML(userInfo.files[i]); + userResponseDiv.appendChild(fileDiv); + } + + return userResponseDiv; +} + +// ============================================================================= +// File admin +// ============================================================================= + +const setupFileSearch = () => { + let fileSearchBtn = document.getElementById("file-search-btn") as HTMLButtonElement; + let fileIDInput = document.getElementById("file-id") as HTMLInputElement; + + fileSearchBtn.addEventListener("click", () => { + let fileID = fileIDInput.value; + fetch(Endpoints.format(Endpoints.AdminFileActions, fileID)).then(async response => { + if (!response.ok) { + alert("Error fetching file: " + await response.text()); + return + } + + let responseDiv = document.getElementById("file-response"); + responseDiv.innerHTML = ""; + + let fileInfo = new AdminFileInfoResponse(await response.json()); + let fileDiv = generateFileActionsHTML(fileInfo); + responseDiv.appendChild(fileDiv); + }).catch(error => { + console.log(error); + }) + }); +} + +const generateFileActionsHTML = (fileInfo: AdminFileInfoResponse): HTMLDivElement => { + let fileResponseDiv = document.createElement("div") as HTMLDivElement; + + fileResponseDiv.className = "bordered-box visible"; + + let fileInfoElement = document.createElement("code"); + fileInfoElement.innerText = `ID: ${fileInfo.id} +Stored Name (encrypted): ${fileInfo.bucketName} +Size: ${fileInfo.size} +Owner ID: ${fileInfo.ownerID} +Modified: ${fileInfo.modified}`; + + fileResponseDiv.appendChild(fileInfoElement); + fileResponseDiv.appendChild(document.createElement("br")); + + let deleteBtnID = `delete-file-${fileInfo.id}`; + let deleteButton = document.createElement("button"); + deleteButton.id = deleteBtnID; + deleteButton.className = "red-button"; + deleteButton.innerText = "Delete File"; + + fileResponseDiv.appendChild(deleteButton); + + deleteButton.addEventListener("click", () => { + if (!confirm("Deleting this file is irreversible. Proceed?")) { + return; + } + + fetch(Endpoints.format(Endpoints.AdminFileActions, fileInfo.id), { + method: "DELETE" + }).then(async response => { + if (!response.ok) { + alert("Failed to delete file! " + await response.text()); + } else { + alert("The file has been deleted!"); + fileResponseDiv.innerHTML = ""; + fileResponseDiv.className = "hidden"; + } + }).catch(error => { + alert("Failed to delete file"); + console.error(error); + }); + }); + + return fileResponseDiv; +} + +if (document.readyState !== "loading") { + init(); +} else { + document.addEventListener("DOMContentLoaded", () => { + init(); + }); +} \ No newline at end of file