package main import ( "bufio" "errors" "flag" "fmt" "io" "os" "path/filepath" "strings" "text/tabwriter" "time" "uncensored-send/internal/auth" "uncensored-send/internal/config" ) const tokenUsage = `uncensored-send token - manage upload credentials Usage: uncensored-send token add [options] generate a token uncensored-send token add --token - read a chosen one from stdin uncensored-send token rotate [--token -] replace the secret, keep everything else uncensored-send token set [options] change limits and flags in place uncensored-send token list [options] uncensored-send token rm [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. "set" changes only what you name on the command line; anything you leave out keeps its current value. Give a limit an empty value to go back to inheriting the server's default, and turn a flag off with "=false": uncensored-send token set friend --max-size= inherit the default again uncensored-send token set friend --vanity=false revoke 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 } switch sub { case "add": if name == "" { return errors.New("token add: a name is required") } // Only worth saying when a file is about to be created somewhere new. // Every other subcommand reports a wrong --data on its own, by finding // no such token or an empty list. warnIfUnusedDataDir(opts.dataDir, opts.tokensPath) return tokenAdd(file, name, opts) case "rotate": if name == "" { return errors.New("token rotate: a name is required") } return tokenRotate(file, name, opts) case "set": if name == "" { return errors.New("token set: a name is required") } return tokenSet(file, name, opts, fs) 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("uncensored-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 /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 vanity names; --vanity=false revokes it") fs.Bool(&o.admin, "admin", "", false, "allow this token to delete anyone's files; --admin=false revokes it") 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 ") return nil } // tokenRotate replaces a token's secret while keeping its name, limits, flags // and history. Anyone still holding the old secret, in a script or in a browser // session, stops being authenticated the moment this returns. func tokenRotate(file *auth.File, name string, opts tokenOptions) error { chosen := opts.chosen if chosen == "-" { read, err := readSecret() if err != nil { return err } chosen = read } var generated string err := file.Update(name, func(t *auth.Token) error { if chosen != "" { return t.SetChosen(chosen) } secret, err := t.SetGenerated() generated = secret return err }) if err != nil { return err } fmt.Printf("Rotated token %q in %s\n\n", name, file.Path()) if generated != "" { fmt.Printf(" %s\n\n", generated) fmt.Println("This is the only time it is shown; only its hash is stored.") } fmt.Println("Anything still using the old secret is now refused, including") fmt.Println("browser sessions, which will have to log in again.") return nil } // tokenSet changes limits and flags in place. Only the options actually given // on the command line are applied, so there is no way to reset something by // forgetting to mention it. func tokenSet(file *auth.File, name string, opts tokenOptions, fs *config.Set) error { var changes []string err := file.Update(name, func(t *auth.Token) error { for _, f := range []struct { flag string value string dst **string }{ {"max-size", opts.maxSize, &t.MaxSize}, {"max-expiry", opts.maxExpiry, &t.MaxExpiry}, {"default-expiry", opts.defExpiry, &t.DefaultExpiry}, } { if !fs.Changed(f.flag) { continue } if f.value == "" { *f.dst = nil // back to inheriting the server default changes = append(changes, "--"+f.flag+" (inherited)") continue } v := f.value *f.dst = &v changes = append(changes, "--"+f.flag+" "+v) } for _, f := range []struct { flag string value bool dst *bool }{ {"vanity", opts.vanity, &t.AllowVanity}, {"admin", opts.admin, &t.Admin}, } { if !fs.Changed(f.flag) { continue } *f.dst = f.value changes = append(changes, fmt.Sprintf("--%s=%t", f.flag, f.value)) } if fs.Changed("token") { return errors.New("use \"uncensored-send token rotate\" to change the secret") } return nil }) if err != nil { return err } if len(changes) == 0 { return errors.New("token set: nothing to change; give at least one option") } fmt.Printf("Updated token %q: %s\n", name, strings.Join(changes, ", ")) 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\tROTATED") for _, t := range tokens { fmt.Fprintf(w, "%s\t%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"), date(t.Rotated)) } return w.Flush() } func kind(t *auth.Token) string { if t.Chosen() { return "chosen" } return "generated" } func date(t time.Time) string { if t.IsZero() { return "never" } return t.Format("2006-01-02") } func inherited(s *string) string { if s == nil { return "(default)" } return *s } func yesNo(b bool) string { if b { return "yes" } return "no" }