258 lines
6.8 KiB
Go
258 lines
6.8 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"text/tabwriter"
|
|
|
|
"send/internal/auth"
|
|
"send/internal/config"
|
|
)
|
|
|
|
const tokenUsage = `send token - manage upload credentials
|
|
|
|
Usage:
|
|
send token add <name> [options] generate a token
|
|
send token add <name> --token - read a chosen one from stdin
|
|
send token list [options]
|
|
send token rm <name> [options]
|
|
|
|
A token grants its own size and lifetime limits. Any limit left unset is
|
|
inherited from the running server's defaults, so a token with no options
|
|
behaves exactly like the anonymous tier but may claim vanity names.
|
|
|
|
Options:
|
|
`
|
|
|
|
func tokenCommand(args []string) error {
|
|
// Asking for help is not a subcommand, and neither is asking for nothing.
|
|
if len(args) == 0 || isHelp(args[0]) {
|
|
tokenUsageTo(os.Stdout)
|
|
if len(args) == 0 {
|
|
return errors.New("token: expected add, list or rm")
|
|
}
|
|
return flag.ErrHelp
|
|
}
|
|
sub, rest := args[0], args[1:]
|
|
|
|
// The name is positional and must come first; stdlib flag stops parsing at
|
|
// the first non-flag argument.
|
|
name := ""
|
|
if len(rest) > 0 && (len(rest[0]) == 0 || rest[0][0] != '-') {
|
|
name, rest = rest[0], rest[1:]
|
|
}
|
|
|
|
var opts tokenOptions
|
|
fs := opts.register()
|
|
fs.SetOutput(os.Stderr)
|
|
|
|
if err := fs.Parse(rest); err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
fs.PrintUsage(os.Stdout, tokenUsage)
|
|
return flag.ErrHelp
|
|
}
|
|
return err
|
|
}
|
|
if opts.tokensPath == "" {
|
|
opts.tokensPath = opts.dataDir + "/tokens.json"
|
|
}
|
|
|
|
// Match the server's permissions for anything this command has to create.
|
|
setUmask()
|
|
|
|
file, err := auth.Load(opts.tokensPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
warnIfUnusedDataDir(opts.dataDir, opts.tokensPath)
|
|
|
|
switch sub {
|
|
case "add":
|
|
if name == "" {
|
|
return errors.New("token add: a name is required")
|
|
}
|
|
return tokenAdd(file, name, opts)
|
|
case "list":
|
|
return tokenList(file)
|
|
case "rm", "remove", "delete":
|
|
if name == "" {
|
|
return errors.New("token rm: a name is required")
|
|
}
|
|
if err := file.Remove(name); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("Removed token %q.\n", name)
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("token: unknown subcommand %q", sub)
|
|
}
|
|
}
|
|
|
|
// warnIfUnusedDataDir flags the most likely mistake with this command: pointing
|
|
// --data somewhere the server does not read, so a freshly minted token is never
|
|
// seen and every request comes back 401. A data directory the server has opened
|
|
// always has an objects/ subdirectory.
|
|
func warnIfUnusedDataDir(dataDir, tokensPath string) {
|
|
if _, err := os.Stat(filepath.Join(dataDir, "objects")); err == nil {
|
|
return
|
|
}
|
|
fmt.Fprintf(os.Stderr,
|
|
"note: %s has no objects/ directory, so no server has used it.\n"+
|
|
" Tokens written to %s are only read by a server started with --data %s\n\n",
|
|
dataDir, tokensPath, dataDir)
|
|
}
|
|
|
|
// tokenOptions are the flags every token subcommand shares.
|
|
type tokenOptions struct {
|
|
dataDir string
|
|
tokensPath string
|
|
maxSize string
|
|
maxExpiry string
|
|
defExpiry string
|
|
chosen string
|
|
vanity bool
|
|
admin bool
|
|
}
|
|
|
|
func (o *tokenOptions) register() *config.Set {
|
|
fs := config.NewSet("send token", config.EnvPrefix)
|
|
fs.String(&o.dataDir, "data", "d", "./data", "DIR", "directory holding the data")
|
|
fs.String(&o.tokensPath, "tokens", "", "", "FILE", "token file location (default <data>/tokens.json)")
|
|
fs.String(&o.maxSize, "max-size", "s", "", "SIZE", "per-upload cap for this token; 'unlimited' to remove it")
|
|
fs.String(&o.maxExpiry, "max-expiry", "e", "", "DURATION", "longest lifetime this token may request; 'never' to remove the cap")
|
|
fs.String(&o.defExpiry, "default-expiry", "", "", "DURATION", "lifetime applied when this token does not ask for one")
|
|
fs.String(&o.chosen, "token", "t", "", "VALUE",
|
|
"use this token instead of a generated one; \"-\" reads it from standard input")
|
|
fs.Bool(&o.vanity, "vanity", "", false, "allow this token to claim custom names")
|
|
fs.Bool(&o.admin, "admin", "", false, "allow this token to delete anyone's files")
|
|
return fs
|
|
}
|
|
|
|
// isHelp recognises the spellings people actually type.
|
|
func isHelp(arg string) bool {
|
|
switch arg {
|
|
case "help", "-h", "--help":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func tokenUsageTo(w io.Writer) {
|
|
var opts tokenOptions
|
|
opts.register().PrintUsage(w, tokenUsage)
|
|
}
|
|
|
|
func tokenAdd(file *auth.File, name string, opts tokenOptions) error {
|
|
chosen := opts.chosen
|
|
if chosen == "-" {
|
|
read, err := readSecret()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
chosen = read
|
|
}
|
|
|
|
var (
|
|
t *auth.Token
|
|
secret string
|
|
err error
|
|
)
|
|
if chosen != "" {
|
|
t, err = auth.NewChosen(name, chosen)
|
|
secret = chosen
|
|
} else {
|
|
t, secret, err = auth.NewGenerated(name)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
t.AllowVanity = opts.vanity
|
|
t.Admin = opts.admin
|
|
// Only options actually given are recorded; everything else stays absent
|
|
// so it keeps tracking the server's defaults.
|
|
if opts.maxSize != "" {
|
|
t.MaxSize = &opts.maxSize
|
|
}
|
|
if opts.maxExpiry != "" {
|
|
t.MaxExpiry = &opts.maxExpiry
|
|
}
|
|
if opts.defExpiry != "" {
|
|
t.DefaultExpiry = &opts.defExpiry
|
|
}
|
|
if err := file.Add(t); err != nil {
|
|
return err
|
|
}
|
|
|
|
if chosen != "" {
|
|
fmt.Printf("Added token %q to %s\n\n", name, file.Path())
|
|
fmt.Println("It is stored under a slow key derivation, so a leak of the token")
|
|
fmt.Println("file does not hand over the passphrase itself. Guessing it online is")
|
|
fmt.Println("rate limited, but a weak choice is still a weak choice.")
|
|
return nil
|
|
}
|
|
fmt.Printf("Added token %q to %s\n\n", name, file.Path())
|
|
fmt.Printf(" %s\n\n", secret)
|
|
fmt.Println("This is the only time it is shown; only its hash is stored.")
|
|
fmt.Println("Send it as: Authorization: Bearer <token>")
|
|
return nil
|
|
}
|
|
|
|
// readSecret reads a token from standard input, so it need not appear in a
|
|
// shell history or in the process list.
|
|
func readSecret() (string, error) {
|
|
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
|
if err != nil && !errors.Is(err, io.EOF) {
|
|
return "", err
|
|
}
|
|
secret := strings.TrimRight(line, "\r\n")
|
|
if secret == "" {
|
|
return "", errors.New("no token on standard input")
|
|
}
|
|
return secret, nil
|
|
}
|
|
|
|
func tokenList(file *auth.File) error {
|
|
tokens := file.List()
|
|
if len(tokens) == 0 {
|
|
fmt.Printf("No tokens in %s\n", file.Path())
|
|
return nil
|
|
}
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(w, "NAME\tKIND\tMAX SIZE\tMAX EXPIRY\tDEFAULT\tVANITY\tADMIN\tCREATED")
|
|
for _, t := range tokens {
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
|
t.Name, kind(t),
|
|
inherited(t.MaxSize), inherited(t.MaxExpiry), inherited(t.DefaultExpiry),
|
|
yesNo(t.AllowVanity), yesNo(t.Admin),
|
|
t.Created.Format("2006-01-02"))
|
|
}
|
|
return w.Flush()
|
|
}
|
|
|
|
func kind(t *auth.Token) string {
|
|
if t.Chosen() {
|
|
return "chosen"
|
|
}
|
|
return "generated"
|
|
}
|
|
|
|
func inherited(s *string) string {
|
|
if s == nil {
|
|
return "(default)"
|
|
}
|
|
return *s
|
|
}
|
|
|
|
func yesNo(b bool) string {
|
|
if b {
|
|
return "yes"
|
|
}
|
|
return "no"
|
|
}
|