mirror of
https://github.com/halverneus/static-file-server.git
synced 2024-11-24 09:05:30 +00:00
Rough draft of feature.
This commit is contained in:
parent
4454460785
commit
084257d34d
515
serve.go
515
serve.go
@ -1,15 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/scrypt"
|
||||
|
||||
"github.com/howeyc/gopass"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "Version 1.1"
|
||||
version = "Version 1.2"
|
||||
|
||||
authHelp = `
|
||||
NAME
|
||||
static-file-server auth
|
||||
|
||||
SYNOPSIS
|
||||
static-file-server auth [ help | -help | --help ]
|
||||
static-file-server auth list
|
||||
static-file-server auth add $username [ $password ]
|
||||
static-file-server auth update $username [ $password ]
|
||||
static-file-server auth remove $username
|
||||
|
||||
DESCRIPTION
|
||||
The Static File Server authentication sub-command is used to securely modify
|
||||
a credential file for use with basic authentication.
|
||||
|
||||
DEPENDENCIES
|
||||
None... not even libc!
|
||||
|
||||
ENVIRONMENT VARIABLES
|
||||
CREDENTIALS
|
||||
The path to a file that contains valid credentials, or the path to a
|
||||
file that will have credentials modified. By having this variable set
|
||||
basic authentication will automatically be used. Credentials must be
|
||||
added to the file using the 'auth add' command prior to use. If no
|
||||
credentials are added, then basic authentication will always fail (fails
|
||||
secure). It is HIGHLY RECOMMENDED to use this with TLS certificates. If
|
||||
you are not using TLS certificates, don't use credentials that are
|
||||
important. Username and password are case-sensitive. Variable must be
|
||||
set to use any authentication sub-commands, with the exception of
|
||||
requesting help.
|
||||
|
||||
COMMANDS
|
||||
add $username [ $password ]
|
||||
Add a new credential to the credential file. If the username is not set
|
||||
or is the same as an existing username, the command will fail. If the
|
||||
password for a user needs to be updated, use 'auth update'. If the
|
||||
password is not supplied, it will be requested during execution.
|
||||
ARGS:
|
||||
$username: The case-sensitive username to be added.
|
||||
$password: The case-sensitive password to be associated with the new
|
||||
username.
|
||||
|
||||
help
|
||||
Prints this help documentation.
|
||||
|
||||
list
|
||||
List all usernames in the credential file.
|
||||
|
||||
remove $username
|
||||
Remove an existing username from the credential file. If the username is
|
||||
not set or doesn't match an existing username, the command will fail.
|
||||
ARGS:
|
||||
$username: The case-sensitive username to be removed.
|
||||
|
||||
update $username [ $password ]
|
||||
Update an existing username with a new password in the credential file.
|
||||
If the username is not set or doesn't match an existing username, the
|
||||
command will fail. If a new user needs to be added, use 'auth add'. If
|
||||
the password is not supplied, it will be requested during execution.
|
||||
ARGS:
|
||||
$username: The case-sensitive username to be updated with a new
|
||||
password.
|
||||
$password: The case-sensitive passwrod to be associated with the
|
||||
existing username.
|
||||
`
|
||||
|
||||
help = `
|
||||
NAME
|
||||
@ -19,6 +96,7 @@ SYNOPSIS
|
||||
static-file-server
|
||||
static-file-server [ help | -help | --help ]
|
||||
static-file-server [ version | -version | --version ]
|
||||
static-file-server auth [ help | -help | --help ]
|
||||
|
||||
DESCRIPTION
|
||||
The Static File Server is intended to be a tiny, fast and simple solution
|
||||
@ -31,6 +109,22 @@ DEPENDENCIES
|
||||
None... not even libc!
|
||||
|
||||
ENVIRONMENT VARIABLES
|
||||
CREDENTIALS
|
||||
The path to a file that contains valid credentials, or the path to a
|
||||
file that will have credentials modified. By having this variable set
|
||||
basic authentication will automatically be used. Credentials must be
|
||||
added to the file using the 'auth add' command prior to use. If no
|
||||
credentials are added, then basic authentication will always fail (fails
|
||||
secure). It is HIGHLY RECOMMENDED to use this with TLS certificates. If
|
||||
you are not using TLS certificates, don't use credentials that are
|
||||
important. Username and password are case-sensitive.
|
||||
FAST_AUTH
|
||||
It is recommended to use CREDENTIALS and not FAST_AUTH. FAST_AUTH is
|
||||
only provided for users that want to share files for a short time only
|
||||
using an unimportant password. If CREDENTIALS is set, FAST_AUTH will be
|
||||
ignored. The value of FAST_AUTH is a colon (:) delimited username and
|
||||
password (for example: FAST_AUTH=user:password). Username and password
|
||||
are case-sensitive.
|
||||
FOLDER
|
||||
The path to the folder containing the contents to be served over
|
||||
HTTP(s). If not supplied, defaults to '/web' (for Docker reasons).
|
||||
@ -58,6 +152,19 @@ ENVIRONMENT VARIABLES
|
||||
start with a forward-slash and NOT end with a forward-slash. If not
|
||||
supplied then no prefix is used.
|
||||
|
||||
COMMANDS
|
||||
[No commands supplied]
|
||||
Serve static files based on passed environment variables. Server will
|
||||
continue to run until shutdown of the service is requested.
|
||||
|
||||
auth
|
||||
The CREDENTIALS environment variable must be set to use the
|
||||
authorization command. The authorization command is used to securely
|
||||
add, modify and remove user credentials for basic authentication. For
|
||||
more information, run 'static-file-server auth help'.
|
||||
|
||||
help
|
||||
Prints this help documentation.
|
||||
USAGE
|
||||
FILE LAYOUT
|
||||
/var/www/sub/my.file
|
||||
@ -106,37 +213,293 @@ USAGE
|
||||
export SHOW_LISTING=false
|
||||
static-file-server
|
||||
Returns 'NOT FOUND': wget http://my.machine/
|
||||
|
||||
export CREDENTIALS=credentials.json
|
||||
export FOLDER=/var/www/sub
|
||||
export PORT=443
|
||||
export TLS_CERT=/etc/server/my.machine.crt
|
||||
export TLS_KEY=/etc/server/my.machine.key
|
||||
static-file-server auth add 'user' 'pass'
|
||||
static-file-server auth add 'john' '12345'
|
||||
static-file-server
|
||||
Retrieve with:
|
||||
wget --user 'user' --password 'pass' --auth-no-challenge \
|
||||
https://my.machine/my.file
|
||||
wget --user 'john' --password '12345' --auth-no-challenge \
|
||||
https://my.machine/my.file
|
||||
|
||||
export FAST_AUTH=user:pass
|
||||
export FOLDER=/var/www/sub
|
||||
export PORT=443
|
||||
export TLS_CERT=/etc/server/my.machine.crt
|
||||
export TLS_KEY=/etc/server/my.machine.key
|
||||
static-file-server
|
||||
Retrieve with:
|
||||
wget --user 'user' --password 'pass' --auth-no-challenge \
|
||||
https://my.machine/my.file
|
||||
`
|
||||
|
||||
credentials map[string]*credential
|
||||
|
||||
envvar struct {
|
||||
credentialFile string
|
||||
fastAuth string
|
||||
folder string
|
||||
host string
|
||||
port string
|
||||
showListing bool
|
||||
tlsCert string
|
||||
tlsKey string
|
||||
urlPrefix string
|
||||
}
|
||||
)
|
||||
|
||||
// credential that is stored and used for authentication.
|
||||
type credential struct {
|
||||
// Salt (random) for uniquely encrypting each password.
|
||||
Salt string `json:"salt"`
|
||||
// Password after it has been encrypted with the salt.
|
||||
Password string `json:"pass"`
|
||||
}
|
||||
|
||||
// matches the passed uncrypted password against the assigned encrypted
|
||||
// password. Returns true if the passwords match.
|
||||
func (c *credential) matches(password string) (matches bool, err error) {
|
||||
matches = false
|
||||
var enc string
|
||||
if enc, err = c.encrypt(password, c.Salt); nil != err {
|
||||
return
|
||||
}
|
||||
matches = enc == c.Password
|
||||
return
|
||||
}
|
||||
|
||||
// update the encrypted credential password with a new, unencrypted password.
|
||||
func (c *credential) update(password string) (err error) {
|
||||
// Create a new salt value.
|
||||
rawSalt := make([]byte, 24)
|
||||
var n int
|
||||
if n, err = io.ReadFull(rand.Reader, rawSalt); nil != err {
|
||||
return
|
||||
}
|
||||
if len(rawSalt) != n {
|
||||
err = errors.New("failed to create random password salt")
|
||||
return
|
||||
}
|
||||
|
||||
// Store salt and encrypted password.
|
||||
c.Salt = base64.StdEncoding.EncodeToString(rawSalt)
|
||||
c.Password, err = c.encrypt(password, c.Salt)
|
||||
return
|
||||
}
|
||||
|
||||
// encrypt a password/salt combination to the encrypted password.
|
||||
func (c *credential) encrypt(password, salt string) (enc string, err error) {
|
||||
// Decode salt from Base64 to raw bytes.
|
||||
var rawSalt []byte
|
||||
if rawSalt, err = base64.StdEncoding.DecodeString(salt); nil != err {
|
||||
return
|
||||
}
|
||||
|
||||
// Encrypt password to bytes. Encryption values determined by current
|
||||
// cryptography suggestion.
|
||||
var rawEnc []byte
|
||||
if rawEnc, err = scrypt.Key(
|
||||
[]byte(password), rawSalt, 16384, 8, 1, 32,
|
||||
); nil != err {
|
||||
return
|
||||
}
|
||||
|
||||
// Encode as Base64.
|
||||
enc = base64.StdEncoding.EncodeToString(rawEnc)
|
||||
return
|
||||
}
|
||||
|
||||
func authAddMain(command string, args []string) (err error) {
|
||||
if 0 == len(args) || 2 < len(args) {
|
||||
return fmt.Errorf(
|
||||
"wrong number of arguments supplied to: '%s add', try '%s help'",
|
||||
command, command,
|
||||
)
|
||||
}
|
||||
username := args[0]
|
||||
var password string
|
||||
if 1 < len(args) {
|
||||
password = args[1]
|
||||
}
|
||||
|
||||
if err = loadCredentials(envvar.credentialFile); nil != err {
|
||||
fmt.Printf(
|
||||
"WARNING: Credential file '%s' doesn't already exist... creating.\n",
|
||||
envvar.credentialFile,
|
||||
)
|
||||
err = nil
|
||||
}
|
||||
if _, found := credentials[username]; found {
|
||||
return fmt.Errorf(
|
||||
"user '%s' already exists, use 'update' in place of 'add'",
|
||||
username,
|
||||
)
|
||||
}
|
||||
if 0 == len(password) {
|
||||
if password, err = getPassword(); nil != err {
|
||||
return
|
||||
}
|
||||
}
|
||||
cred := &credential{}
|
||||
if err = cred.update(password); nil != err {
|
||||
return
|
||||
}
|
||||
credentials[username] = cred
|
||||
return saveCredentials(envvar.credentialFile)
|
||||
}
|
||||
|
||||
func authListMain(command string, args []string) (err error) {
|
||||
if 0 != len(args) {
|
||||
return fmt.Errorf(
|
||||
"'%s list' does not accept any arguments, try '%s help'",
|
||||
command, command,
|
||||
)
|
||||
}
|
||||
|
||||
if err = loadCredentials(envvar.credentialFile); nil != err {
|
||||
return
|
||||
}
|
||||
for username := range credentials {
|
||||
fmt.Println(username)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func authRemoveMain(command string, args []string) (err error) {
|
||||
if 1 != len(args) {
|
||||
return fmt.Errorf(
|
||||
"'%s remove' requires exactly one argument, try '%s help'",
|
||||
command, command,
|
||||
)
|
||||
}
|
||||
|
||||
username := args[0]
|
||||
if err = loadCredentials(envvar.credentialFile); nil != err {
|
||||
return
|
||||
}
|
||||
if _, found := credentials[username]; !found {
|
||||
return fmt.Errorf("user '%s' doesn't exist", username)
|
||||
}
|
||||
delete(credentials, username)
|
||||
return saveCredentials(envvar.credentialFile)
|
||||
}
|
||||
|
||||
func authUpdateMain(command string, args []string) (err error) {
|
||||
if 0 == len(args) || 2 < len(args) {
|
||||
return fmt.Errorf(
|
||||
"wrong number of arguments supplied to: '%s update', try '%s help'",
|
||||
command, command,
|
||||
)
|
||||
}
|
||||
username := args[0]
|
||||
var password string
|
||||
if 1 < len(args) {
|
||||
password = args[1]
|
||||
}
|
||||
|
||||
if err = loadCredentials(envvar.credentialFile); nil != err {
|
||||
return
|
||||
}
|
||||
cred, found := credentials[username]
|
||||
if !found {
|
||||
return fmt.Errorf(
|
||||
"user '%s' doesn't exist, use 'add' in place of 'update'",
|
||||
username,
|
||||
)
|
||||
}
|
||||
if 0 == len(password) {
|
||||
if password, err = getPassword(); nil != err {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = cred.update(password); nil != err {
|
||||
return
|
||||
}
|
||||
return saveCredentials(envvar.credentialFile)
|
||||
}
|
||||
|
||||
func authMain(command string, args []string) {
|
||||
// Subcommand not supplied. Redirect to help.
|
||||
if 0 == len(args) {
|
||||
log.Fatalf(
|
||||
"no arguments supplied to: '%s', try '%s help'",
|
||||
command, command,
|
||||
)
|
||||
}
|
||||
|
||||
subCommand := args[0]
|
||||
args = args[1:]
|
||||
|
||||
if strings.Contains(subCommand, "help") {
|
||||
fmt.Println(authHelp)
|
||||
return
|
||||
}
|
||||
|
||||
if 0 == len(envvar.credentialFile) {
|
||||
log.Fatalln("credential file required but not set")
|
||||
}
|
||||
|
||||
var err error
|
||||
switch subCommand {
|
||||
case "add":
|
||||
err = authAddMain(command, args)
|
||||
case "list":
|
||||
err = authListMain(command, args)
|
||||
case "remove":
|
||||
err = authRemoveMain(command, args)
|
||||
case "update":
|
||||
err = authUpdateMain(command, args)
|
||||
default:
|
||||
err = fmt.Errorf(
|
||||
"unrecognized command '%s %s', try '%s help'",
|
||||
command, subCommand, command,
|
||||
)
|
||||
}
|
||||
if nil != err {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
// Collect environment variables.
|
||||
envvar.credentialFile = env("CREDENTIALS", "")
|
||||
envvar.fastAuth = env("FAST_AUTH", "")
|
||||
envvar.folder = env("FOLDER", "/web") + "/"
|
||||
envvar.host = env("HOST", "")
|
||||
envvar.port = env("PORT", "8080")
|
||||
envvar.showListing = envAsBool("SHOW_LISTING", true)
|
||||
envvar.tlsCert = env("TLS_CERT", "")
|
||||
envvar.tlsKey = env("TLS_KEY", "")
|
||||
envvar.urlPrefix = env("URL_PREFIX", "")
|
||||
|
||||
// Evaluate and execute subcommand if supplied.
|
||||
appName := os.Args[0]
|
||||
if 1 < len(os.Args) {
|
||||
arg := os.Args[1]
|
||||
command := fmt.Sprintf("%s %s", appName, arg)
|
||||
switch {
|
||||
case strings.Contains(arg, "help"):
|
||||
fmt.Println(help)
|
||||
case strings.Contains(arg, "version"):
|
||||
fmt.Println(version)
|
||||
case "auth" == arg:
|
||||
authMain(command, os.Args[2:])
|
||||
default:
|
||||
name := os.Args[0]
|
||||
log.Fatalf("Unknown argument: %s. Try '%s help'.", arg, name)
|
||||
log.Fatalf("Unknown argument: %s. Try '%s help'.", arg, appName)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Collect environment variables.
|
||||
folder := env("FOLDER", "/web") + "/"
|
||||
host := env("HOST", "")
|
||||
port := env("PORT", "8080")
|
||||
showListing := envAsBool("SHOW_LISTING", true)
|
||||
tlsCert := env("TLS_CERT", "")
|
||||
tlsKey := env("TLS_KEY", "")
|
||||
urlPrefix := env("URL_PREFIX", "")
|
||||
|
||||
// If HTTPS is to be used, verify both TLS_* environment variables are set.
|
||||
if 0 < len(tlsCert) || 0 < len(tlsKey) {
|
||||
if 0 == len(tlsCert) || 0 == len(tlsKey) {
|
||||
if 0 < len(envvar.tlsCert) || 0 < len(envvar.tlsKey) {
|
||||
if 0 == len(envvar.tlsCert) || 0 == len(envvar.tlsKey) {
|
||||
log.Fatalln(
|
||||
"If value for environment variable 'TLS_CERT' or 'TLS_KEY' is set " +
|
||||
"then value for environment variable 'TLS_KEY' or 'TLS_CERT' must " +
|
||||
@ -146,28 +509,80 @@ func main() {
|
||||
}
|
||||
|
||||
// If the URL path prefix is to be used, verify it is properly formatted.
|
||||
if 0 < len(urlPrefix) &&
|
||||
(!strings.HasPrefix(urlPrefix, "/") || strings.HasSuffix(urlPrefix, "/")) {
|
||||
if 0 < len(envvar.urlPrefix) &&
|
||||
(!strings.HasPrefix(envvar.urlPrefix, "/") || strings.HasSuffix(envvar.urlPrefix, "/")) {
|
||||
log.Fatalln(
|
||||
"Value for environment variable 'URL_PREFIX' must start " +
|
||||
"with '/' and not end with '/'. Example: '/my/prefix'",
|
||||
)
|
||||
}
|
||||
|
||||
// Determine whether basic authentication is needed.
|
||||
auth := func(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return handler
|
||||
}
|
||||
if 0 < len(envvar.credentialFile) || 0 < len(envvar.fastAuth) {
|
||||
if 0 < len(envvar.credentialFile) {
|
||||
if err := loadCredentials(envvar.credentialFile); nil != err {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
} else {
|
||||
credentials = make(map[string]*credential)
|
||||
parts := strings.Split(envvar.fastAuth, ":")
|
||||
if 2 != len(parts) || 0 == len(parts[0]) || 0 == len(parts[1]) {
|
||||
log.Fatalln(
|
||||
"'FAST_AUTH' must have exactly one colon (:) to separate " +
|
||||
"the username from the password (username:password)",
|
||||
)
|
||||
}
|
||||
cred := &credential{}
|
||||
if err := cred.update(parts[1]); nil != err {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
credentials[parts[0]] = cred
|
||||
}
|
||||
auth = func(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok || 0 == len(username) || 0 == len(password) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cred, ok := credentials[username]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
allowed, err := cred.matches(password)
|
||||
if nil != err {
|
||||
log.Println(err)
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
handler(w, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Choose and set the appropriate, optimized static file serving function.
|
||||
var handler http.HandlerFunc
|
||||
if 0 == len(urlPrefix) {
|
||||
handler = handleListing(showListing, basicHandler(folder))
|
||||
if 0 == len(envvar.urlPrefix) {
|
||||
handler = handleListing(envvar.showListing, basicHandler(envvar.folder))
|
||||
} else {
|
||||
handler = handleListing(showListing, prefixHandler(folder, urlPrefix))
|
||||
handler = handleListing(envvar.showListing, prefixHandler(envvar.folder, envvar.urlPrefix))
|
||||
}
|
||||
http.HandleFunc("/", handler)
|
||||
http.HandleFunc("/", auth(handler))
|
||||
|
||||
// Serve files over HTTP or HTTPS based on paths to TLS files being provided.
|
||||
if 0 == len(tlsCert) {
|
||||
log.Fatalln(http.ListenAndServe(host+":"+port, nil))
|
||||
if 0 == len(envvar.tlsCert) {
|
||||
log.Fatalln(http.ListenAndServe(envvar.host+":"+envvar.port, nil))
|
||||
} else {
|
||||
log.Fatalln(http.ListenAndServeTLS(host+":"+port, tlsCert, tlsKey, nil))
|
||||
log.Fatalln(http.ListenAndServeTLS(envvar.host+":"+envvar.port, envvar.tlsCert, envvar.tlsKey, nil))
|
||||
}
|
||||
}
|
||||
|
||||
@ -176,7 +591,7 @@ func main() {
|
||||
// will attempt to retrieve the index file of that directory.
|
||||
func handleListing(show bool, serve http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if show || strings.HasSuffix(r.URL.Path, "/") {
|
||||
if !show && strings.HasSuffix(r.URL.Path, "/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
@ -203,6 +618,58 @@ func prefixHandler(folder, urlPrefix string) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// loadCredentials from an existing credentials file.
|
||||
func loadCredentials(filename string) (err error) {
|
||||
credentials = make(map[string]*credential)
|
||||
if 0 == len(filename) {
|
||||
return errors.New("credential file name not set but is required")
|
||||
}
|
||||
var contents []byte
|
||||
if contents, err = ioutil.ReadFile(filename); nil != err {
|
||||
return
|
||||
}
|
||||
return json.Unmarshal(contents, &credentials)
|
||||
}
|
||||
|
||||
// saveCredentials to the specified credentials file.
|
||||
func saveCredentials(filename string) (err error) {
|
||||
if 0 == len(filename) {
|
||||
return errors.New("credential file name not set but is required")
|
||||
}
|
||||
var contents []byte
|
||||
if contents, err = json.Marshal(&credentials); nil != err {
|
||||
return
|
||||
}
|
||||
return ioutil.WriteFile(filename, contents, 0600)
|
||||
}
|
||||
|
||||
// getPassword from the user via the terminal. Mask all characters.
|
||||
func getPassword() (password string, err error) {
|
||||
maskInput := true
|
||||
var rawPassword []byte
|
||||
var rawConfirmPassword []byte
|
||||
if rawPassword, err = gopass.GetPasswdPrompt(
|
||||
"New password:", maskInput, os.Stdin, os.Stdout,
|
||||
); nil != err {
|
||||
return
|
||||
}
|
||||
if 0 == len(rawPassword) {
|
||||
err = errors.New("password may not be empty")
|
||||
return
|
||||
}
|
||||
if rawConfirmPassword, err = gopass.GetPasswdPrompt(
|
||||
"Confirm password:", maskInput, os.Stdin, os.Stdout,
|
||||
); nil != err {
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(rawPassword, rawConfirmPassword) {
|
||||
err = errors.New("passwords do not match")
|
||||
return
|
||||
}
|
||||
password = string(rawPassword)
|
||||
return
|
||||
}
|
||||
|
||||
// env returns the value for an environment variable or, if not set, a fallback
|
||||
// value.
|
||||
func env(key, fallback string) string {
|
||||
|
Loading…
Reference in New Issue
Block a user