Files
uncensored-send/token.go
T
2026-09-13 10:34:28 +02:00

379 lines
10 KiB
Go

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 <name> [options] generate a token
uncensored-send token add <name> --token - read a chosen one from stdin
uncensored-send token rotate <name> [--token -] replace the secret, keep everything else
uncensored-send token set <name> [options] change limits and flags in place
uncensored-send token list [options]
uncensored-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.
"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 custom 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 <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; --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 <token>")
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"
}