diff --git a/.gitignore b/.gitignore index ec3aad7..f0971da 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,7 @@ /.pytest_cache /.tox /build -/cli/build -/cli/dist +/cli/cli /dist /fluffy/static/**/*.hash /fluffy/static/app.css diff --git a/Makefile b/Makefile index 2602393..2cbbcf6 100644 --- a/Makefile +++ b/Makefile @@ -5,10 +5,15 @@ export FLUFFY_SETTINGS := $(CURDIR)/settings.py .PHONY: minimal minimal: $(VENV) assets settings.py install-hooks -$(VENV): setup.py cli/setup.py requirements.txt requirements-dev.txt +cli/cli: cli/main.go cli/go.mod + cd cli && go build -o cli + +$(VENV): setup.py requirements.txt requirements-dev.txt cli/cli rm -rf $@ virtualenv -ppython3.11 $@ - $@/bin/pip install -r requirements.txt -r requirements-dev.txt -e cli -e . + $@/bin/pip install -r requirements.txt -r requirements-dev.txt -e . + ln -fs ../../cli/cli $@/bin/fput + ln -fs ../../cli/cli $@/bin/fpb fluffy/static/app.css: $(VENV) $(wildcard fluffy/static/scss/*.scss) $(BIN)/pysassc fluffy/static/scss/app.scss $@ @@ -46,6 +51,7 @@ dev: $(VENV) fluffy/static/app.css .PHONY: test test: $(VENV) + cd cli && go test -v ./... $(BIN)/coverage erase COVERAGE_PROCESS_START=$(CURDIR)/.coveragerc \ $(BIN)/py.test --tb=native -vv tests/ diff --git a/cli/debian/.gitignore b/cli/debian/.gitignore deleted file mode 100644 index cb916eb..0000000 --- a/cli/debian/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -/*.debhelper -/*.log -/*.substvars -/debhelper-build-stamp -/files -/fluffy diff --git a/cli/debian/changelog b/cli/debian/changelog deleted file mode 100644 index c56c4db..0000000 --- a/cli/debian/changelog +++ /dev/null @@ -1,12 +0,0 @@ -fluffy (1.8.0) unstable; urgency=medium - - * Add --tee to fpb (in 1.8.0) - * Add --direct-link to fpb and fput (in 1.7.0) - - -- Chris Kuehl Fri, 19 Jan 2018 23:01:01 -0800 - -fluffy (1.6.0) unstable; urgency=medium - - * Initial Debian release. - - -- Chris Kuehl Fri, 19 Jan 2018 23:01:01 -0800 diff --git a/cli/debian/compat b/cli/debian/compat deleted file mode 100644 index ec63514..0000000 --- a/cli/debian/compat +++ /dev/null @@ -1 +0,0 @@ -9 diff --git a/cli/debian/control b/cli/debian/control deleted file mode 100644 index 41f23cf..0000000 --- a/cli/debian/control +++ /dev/null @@ -1,17 +0,0 @@ -Source: fluffy -Section: utils -Priority: extra -Maintainer: Chris Kuehl -Build-Depends: debhelper (>= 9) -Standards-Version: 3.9.7 -Vcs-Git: https://github.com/chriskuehl/fluffy -Vcs-Browser: https://github.com/chriskuehl/fluffy -Homepage: https://github.com/chriskuehl/fluffy - -Package: fluffy -Architecture: all -Depends: python3 -Description: command-line tools for uploading to fluffy servers - This package provides two command line scripts: fpb and fput. - . - fpb can be used to paste text to a fluffy server; fput is to upload files. diff --git a/cli/debian/rules b/cli/debian/rules deleted file mode 100755 index 97d473c..0000000 --- a/cli/debian/rules +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/make -f -VERSION = $(shell python -c 'import fluffy_cli; print(fluffy_cli.__version__)') - -%: - # Specifying buildsystem manually because otherwise it tries to be - # "smart" and use dh_python. - dh $@ --buildsystem makefile - -override_dh_auto_build: ; - -override_dh_auto_test: ; - -override_dh_install: - mkdir -p debian/fluffy/usr/bin - cp fluffy_cli/main.py debian/fluffy/usr/bin/fpb - # This is pretty janky... we don't want to distribute the entire Python - # package (because the path will differ across distributions, e.g. - # /usr/lib/python3.4/dist-packages vs 3.5 vs 3.6) so we just insert it into - # the file - sed -i "s/^from fluffy_cli import __version__$$/__version__ = '$(VERSION)'/" debian/fluffy/usr/bin/fpb - ln -s fpb debian/fluffy/usr/bin/fput diff --git a/cli/debian/source/format b/cli/debian/source/format deleted file mode 100644 index 89ae9db..0000000 --- a/cli/debian/source/format +++ /dev/null @@ -1 +0,0 @@ -3.0 (native) diff --git a/cli/fluffy_cli/__init__.py b/cli/fluffy_cli/__init__.py deleted file mode 100644 index b280975..0000000 --- a/cli/fluffy_cli/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = '1.8.0' diff --git a/cli/fluffy_cli/main.py b/cli/fluffy_cli/main.py deleted file mode 100755 index 35cee6d..0000000 --- a/cli/fluffy_cli/main.py +++ /dev/null @@ -1,207 +0,0 @@ -#!/usr/bin/env python3 -"""Upload files or paste to fluffy. - -It can be invoked directly, but is intended to be invoked by two aliases, -"fput" and "fpb". fput uploads files, fpb pastes text. - -To install the cli, run `pip install fluffy`. -""" -import argparse -import getpass -import json -import os.path -import re -import sys - -import requests -from fluffy_cli import __version__ - -DESCRIPTION = '''\ -fluffy is a simple file-sharing web app. You can upload files, or paste text. - -By default, the public instance of fluffy is used: https://fluffy.cc - -If you'd like to instead use a different instance (for example, one run -internally by your company), you can specify the --server option. - -To make that permanent, you can create a config file with contents similar to: - - {"server": "https://fluffy.my.corp"} - -This file can be placed at either /etc/fluffy.json or ~/.config/fluffy.json. -''' - - -def bold(text): - if sys.stdout.isatty(): - return f'\033[1m{text}\033[0m' - else: - return text - - -def get_config(): - config = {'server': 'https://fluffy.cc'} - for path in ('/etc/fluffy.json', os.path.expanduser('~/.config/fluffy.json')): - try: - with open(path) as f: - j = json.load(f) - if not isinstance(j, dict): - raise ValueError( - f'Expected to parse dict, but the JSON was type "{type(j)}" instead.', - ) - for key, value in j.items(): - config[key] = value - except FileNotFoundError: - pass - except Exception: - print(bold(f'Error parsing config file "{path}". Is it valid JSON?')) - raise - return config - - -def upload(server, paths, auth, direct_link): - files = (('file', sys.stdin.buffer if path == '-' else open(path, 'rb')) for path in paths) - req = requests.post( - server + '/upload?json', - files=files, - allow_redirects=False, - auth=auth, - ) - if req.status_code != 200: - print(f'Failed to upload (status code {req.status_code}):') - print(req.text) - return 1 - else: - resp = req.json() - if direct_link: - for filename, details in resp['uploaded_files'].items(): - print(bold(details['raw'])) - else: - print(bold(resp['redirect'])) - - -def paste(server, path, language, highlight_regex, auth, direct_link, tee): - content = '' - if path == '-': - if tee: - # We should stream it - for line in sys.stdin: - content += line - print(line, flush=True, end='') # The line will already have a newline - else: - content = sys.stdin.read() - else: - with open(path) as f: - content = f.read() - - req = requests.post( - server + '/paste?json', - data={'text': content, 'language': language}, - allow_redirects=False, - auth=auth, - ) - if req.status_code != 200: - print(f'Failed to paste (status code {req.status_code}):') - print(req.text) - return 1 - else: - resp = req.json() - - if direct_link: - location = resp['uploaded_files']['paste']['raw'] - else: - location = resp['redirect'] - - if highlight_regex: - matches = [] - for i, line in enumerate(content.splitlines()): - if highlight_regex.search(line): - matches.append(i + 1) - - # squash lines next to each-other - squashed = [] - for match in matches: - if not squashed or squashed[-1][1] != match - 1: - squashed.append([match, match]) - else: - squashed[-1][1] = match - - if matches: - location += '#' + ','.join( - 'L{}'.format( - '{}-{}'.format(*match) - if match[0] != match[1] - else match[0], - ) for match in squashed - ) - - if tee: - print(bold('\n---')) - print(bold(location)) - - -class FluffyArgFormatter( - argparse.ArgumentDefaultsHelpFormatter, - argparse.RawDescriptionHelpFormatter, -): - pass - - -def upload_main(argv=None): - config = get_config() - parser = argparse.ArgumentParser( - description='Upload files to fluffy.\n\n' + DESCRIPTION, - formatter_class=FluffyArgFormatter, - ) - parser.add_argument('--server', default=config['server'], type=str, help='server to upload to') - parser.add_argument('--version', action='version', version=f'%(prog)s {__version__}') - parser.add_argument('--auth', dest='auth', action='store_true', help='use HTTP Basic auth') - parser.add_argument('--no-auth', dest='auth', action='store_false', help='do not use HTTP Basic auth') - parser.set_defaults(auth=config.get('auth', False)) - parser.add_argument( - '-u', '--username', type=str, - default=config.get('username', getpass.getuser()), - help='username for HTTP Basic auth', - ) - parser.add_argument('--direct-link', action='store_true', help='return direct links to the uploads') - parser.add_argument('file', type=str, nargs='+', help='path to file(s) to upload', default='-') - args = parser.parse_args(argv) - auth = None - if args.auth: - auth = args.username, getpass.getpass(f'Password for {args.username}: ') - return upload(args.server, args.file, auth, args.direct_link) - - -def paste_main(argv=None): - config = get_config() - parser = argparse.ArgumentParser( - description='Paste text to fluffy.\n\n' + DESCRIPTION, - formatter_class=FluffyArgFormatter, - ) - parser.add_argument('--server', default=config['server'], type=str, help='server to upload to') - parser.add_argument('--version', action='version', version=f'%(prog)s {__version__}') - parser.add_argument('-l', '--language', type=str, default='autodetect', help='language for syntax highlighting') - parser.add_argument('-r', '--regex', type=re.compile, help='regex of lines to highlight') - parser.add_argument('--auth', dest='auth', action='store_true', help='use HTTP Basic auth') - parser.add_argument('--no-auth', dest='auth', action='store_false', help='do not use HTTP Basic auth') - parser.set_defaults(auth=config.get('auth', False)) - parser.add_argument( - '-u', '--username', type=str, - default=config.get('username', getpass.getuser()), - help='username for HTTP Basic auth', - ) - parser.add_argument('--direct-link', action='store_true', help='return a direct link to the text (not HTML)') - parser.add_argument('--tee', action='store_true', help='Stream the stdin to stdout before creating the paste') - parser.add_argument('file', type=str, nargs='?', help='path to file to paste', default='-') - args = parser.parse_args(argv) - auth = None - if args.auth: - auth = args.username, getpass.getpass(f'Password for {args.username}: ') - return paste(args.server, args.file, args.language, args.regex, auth, args.direct_link, args.tee) - - -if __name__ == '__main__': - if sys.argv[0].endswith('fpb'): - exit(paste_main()) - else: - exit(upload_main()) diff --git a/cli/fpb b/cli/fpb new file mode 120000 index 0000000..76ec9f5 --- /dev/null +++ b/cli/fpb @@ -0,0 +1 @@ +cli \ No newline at end of file diff --git a/cli/fput b/cli/fput new file mode 120000 index 0000000..76ec9f5 --- /dev/null +++ b/cli/fput @@ -0,0 +1 @@ +cli \ No newline at end of file diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 0000000..3e8fc91 --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,15 @@ +module github.com/chriskuehl/fluffy/cli + +go 1.23.0 + +require ( + github.com/adrg/xdg v0.4.0 + github.com/spf13/cobra v1.8.0 + golang.org/x/term v0.16.0 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/sys v0.16.0 // indirect +) diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 0000000..eb3efb5 --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,26 @@ +github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= +github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 0000000..1d9061b --- /dev/null +++ b/cli/main.go @@ -0,0 +1,452 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "regexp" + "runtime/debug" + + "os/user" + "path" + "strings" + "syscall" + + "github.com/adrg/xdg" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +const description = `fluffy is a simple file-sharing web app. You can upload files, or paste text. + +By default, the public instance of fluffy is used: https://fluffy.cc + +If you'd like to instead use a different instance (for example, one run +internally by your company), you can specify the --server option. + +To make that permanent, you can create a config file with contents similar to: + + {"server": "https://fluffy.my.corp"} + +This file can be placed at either /etc/fluffy.json or $XDG_CONFIG_HOME/fluffy.json. +` +const defaultServer = "https://fluffy.cc" + +type settings struct { + Server string `json:"server"` + Auth bool `json:"auth"` + Username string `json:"username"` +} + +func populateSettingsFromFile(configPath string, s *settings) error { + contents, err := os.ReadFile(configPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } else { + return err + } + } + if err := json.Unmarshal(contents, s); err != nil { + return fmt.Errorf("error parsing config file %s: %w", configPath, err) + } + return nil +} + +func getSettings() (*settings, error) { + currentUser, err := user.Current() + if err != nil { + return nil, fmt.Errorf("fetching current user: %w", err) + } + + s := settings{ + Server: defaultServer, + Username: currentUser.Username, + } + + for _, configPath := range []string{ + "/etc/fluffy.json", + path.Join(xdg.ConfigHome, "fluffy.json"), + } { + err := populateSettingsFromFile(configPath, &s) + if err != nil { + return nil, fmt.Errorf("reading config file %s: %w", configPath, err) + } + } + + return &s, nil +} + +type credentials struct { + username string + password string +} + +func wrapWithAuth( + fn func(cmd *cobra.Command, args []string, creds *credentials) error, +) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + var creds *credentials + flags := cmd.Flags() + auth, _ := flags.GetBool("auth") + + if auth { + server, _ := flags.GetString("server") + user, _ := flags.GetString("user") + fmt.Fprintf(os.Stderr, "Server: %s\n", server) + fmt.Fprintf(os.Stderr, "Password for %s: ", user) + passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("reading password: %w", err) + } + creds = &credentials{ + username: user, + password: string(passwordBytes), + } + } + + return fn(cmd, args, creds) + } +} + +func regexHighlightFragment(regex *regexp.Regexp, content *bytes.Buffer) (string, error) { + scanner := bufio.NewScanner(strings.NewReader(content.String())) + matches := []int{} + for i := 0; scanner.Scan(); { + line := scanner.Text() + if regex.MatchString(line) { + matches = append(matches, i) + } + i++ + } + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("scanning: %w", err) + } + + // Squash consecutive matches. + groups := []string{} + for i := 0; i < len(matches); i++ { + start := matches[i] + end := start + for ; len(matches) > i+1 && matches[i+1] == end+1; i++ { + end++ + } + if start == end { + groups = append(groups, fmt.Sprintf("L%d", start+1)) + } else { + groups = append(groups, fmt.Sprintf("L%d-%d", start+1, end+1)) + } + } + + return strings.Join(groups, ","), nil +} + +// Variant of bufio.ScanLines which includes the newline character in the token. +// +// This is desired so that we don't erroneously insert a newline at the end of a final line which +// isn't present in the input. bufio.ScanLines has no way to differentiate whether or not the final +// line has a newline or not. +func ScanLinesWithEOL(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + if i := bytes.IndexByte(data, '\n'); i >= 0 { + // We have a full newline-terminated line. + return i + 1, data[0 : i+1], nil + } + // If we're at EOF, we have a final, non-terminated line. Return it. + if atEOF { + return len(data), data, nil + } + // Request more data. + return 0, nil, nil +} + +var fpbCommand = &cobra.Command{ + Use: "fpb [file]", + Long: `Paste text to fluffy. + +Example usage: + + Paste a file: + fpb some-file.txt + + Pipe the output of a command: + some-command | fpb + + Specify a language to highlight text with: + fpb -l python some_file.py + (Default is to auto-detect the language. You can use "rendered-markdown" for Markdown.) + +` + description, + Args: cobra.MaximumNArgs(1), + SilenceUsage: true, + + RunE: wrapWithAuth(func(cmd *cobra.Command, args []string, creds *credentials) error { + server, _ := cmd.Flags().GetString("server") + tee, _ := cmd.Flags().GetBool("tee") + language, _ := cmd.Flags().GetString("language") + directLink, _ := cmd.Flags().GetBool("direct-link") + + path := "-" + if len(args) > 0 { + path = args[0] + } + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + writer.WriteField("language", language) + + content := &bytes.Buffer{} + if path == "-" { + scanner := bufio.NewScanner(os.Stdin) + scanner.Split(ScanLinesWithEOL) + for scanner.Scan() { + line := scanner.Text() + if _, err := content.WriteString(line); err != nil { + return fmt.Errorf("writing to buffer: %w", err) + } + if tee { + fmt.Print(line) + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("reading from stdin: %w", err) + } + } else { + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("opening file: %w", err) + } + defer file.Close() + if _, err := io.Copy(content, file); err != nil { + return fmt.Errorf("copying file: %w", err) + } + } + writer.WriteField("text", content.String()) + + if err := writer.Close(); err != nil { + return fmt.Errorf("closing writer: %w", err) + } + + req, err := http.NewRequest("POST", server+"/paste?json", body) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + if creds != nil { + req.SetBasicAuth(creds.username, creds.password) + } + q := req.URL.Query() + q.Add("language", language) + req.URL.RawQuery = q.Encode() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + fmt.Fprintf(os.Stderr, "Unexpected status code: %d\n", resp.StatusCode) + fmt.Fprintf(os.Stderr, "Error:\n") + err := fmt.Errorf("unexpected status code: %d", resp.StatusCode) + if _, copyErr := io.Copy(os.Stderr, resp.Body); copyErr != nil { + return fmt.Errorf("copying error: %w for %w", copyErr, err) + } + return err + } + + var result struct { + Redirect string `json:"redirect"` + UploadedFiles struct { + Paste struct { + Raw string `json:"raw"` + } `json:"paste"` + } `json:"uploaded_files"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + + location := result.Redirect + if directLink { + location = result.UploadedFiles.Paste.Raw + } + + if regex.r != nil { + highlight, err := regexHighlightFragment(regex.r, content) + if err != nil { + return fmt.Errorf("highlighting: %w", err) + } + location += "#" + highlight + } + + fmt.Println(bold(location)) + return nil + }), +} + +var fputCommand = &cobra.Command{ + Use: "fput file [file ...]", + Long: "Upload files to fluffy.\n\n" + description, + Args: cobra.MinimumNArgs(1), + SilenceUsage: true, + RunE: wrapWithAuth(func(cmd *cobra.Command, args []string, creds *credentials) error { + directLink, _ := cmd.Flags().GetBool("direct-link") + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + for _, path := range args { + part, err := writer.CreateFormFile("file", filepath.Base(path)) + if err != nil { + return fmt.Errorf("creating form file: %w", err) + } + if path == "-" { + if _, err := io.Copy(part, os.Stdin); err != nil { + return fmt.Errorf("copying stdin: %w", err) + } + } else { + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("opening file: %w", err) + } + defer file.Close() + if _, err := io.Copy(part, file); err != nil { + return fmt.Errorf("copying file: %w", err) + } + } + } + if err := writer.Close(); err != nil { + return fmt.Errorf("closing writer: %w", err) + } + + server, _ := cmd.Flags().GetString("server") + req, err := http.NewRequest("POST", server+"/upload?json", body) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + if creds != nil { + req.SetBasicAuth(creds.username, creds.password) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + fmt.Fprintf(os.Stderr, "Unexpected status code: %d\n", resp.StatusCode) + fmt.Fprintf(os.Stderr, "Error:\n") + err := fmt.Errorf("unexpected status code: %d", resp.StatusCode) + if _, copyErr := io.Copy(os.Stderr, resp.Body); copyErr != nil { + return fmt.Errorf("copying error: %w for %w", copyErr, err) + } + return err + } + + var result struct { + Redirect string `json:"redirect"` + UploadedFiles map[string]struct { + Raw string `json:"raw"` + } `json:"uploaded_files"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + + if directLink { + for _, uploadedFile := range result.UploadedFiles { + fmt.Println(bold(uploadedFile.Raw)) + } + } else { + fmt.Println(bold(result.Redirect)) + } + return nil + }), +} + +func bold(s string) string { + if term.IsTerminal(int(os.Stdout.Fd())) { + return "\x1b[1m" + s + "\x1b[0m" + } else { + return s + } +} + +type regexpValue struct { + r *regexp.Regexp +} + +func (v *regexpValue) String() string { + if v.r == nil { + return "" + } + return v.r.String() +} + +func (v *regexpValue) Set(s string) error { + r, err := regexp.Compile(s) + if err != nil { + return err + } + v.r = r + return nil +} + +func (v *regexpValue) Type() string { + return "regex" +} + +var regex = regexpValue{} + +func init() { + buildInfo, ok := debug.ReadBuildInfo() + if !ok { + panic("could not read build info") + } + version := fmt.Sprintf("%s/%s", buildInfo.Main.Version, buildInfo.GoVersion) + fpbCommand.Version = version + fputCommand.Version = version + + settings, err := getSettings() + if err != nil { + panic(fmt.Errorf("getting settings: %w", err)) + } + + addCommonOpts := func(command *cobra.Command) { + command.Flags().String("server", settings.Server, "server to upload to") + command.Flags().Bool("auth", settings.Auth, "use HTTP Basic auth") + command.Flags().StringP("user", "u", settings.Username, "username for HTTP Basic auth") + command.Flags().Bool("direct-link", false, "return direct link to the uploads") + } + + addCommonOpts(fputCommand) + addCommonOpts(fpbCommand) + + fpbCommand.Flags().StringP("language", "l", "autodetect", "language for syntax highlighting") + fpbCommand.Flags().VarP(®ex, "regex", "r", "regex of lines to highlight") + + fpbCommand.Flags().Bool("tee", false, "stream the stdin to stdout before creating the paste") +} + +func main() { + command := fputCommand + if strings.HasPrefix(filepath.Base(os.Args[0]), "fpb") { + command = fpbCommand + } + if err := command.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/cli/main_test.go b/cli/main_test.go new file mode 100644 index 0000000..dca3d01 --- /dev/null +++ b/cli/main_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "bytes" + "regexp" + "testing" +) + +func TestRegexHighlightFragment(t *testing.T) { + tests := []struct { + name string + regexp string + content string + want string + }{ + { + name: "single match", + regexp: "foo", + content: `foo +bar +baz`, + want: "L1", + }, + { + name: "multiple matches", + regexp: "foo", + content: `foo +bar +foo +foo +baz +foo`, + want: "L1,L3-4,L6", + }, + { + name: "everything matches", + regexp: ".", + content: `foo +bar +foo +foo +baz +foo`, + want: "L1-6", + }, + { + name: "no matches", + regexp: "qux", + content: `foo +bar +baz`, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := regexHighlightFragment(regexp.MustCompile(tt.regexp), bytes.NewBufferString(tt.content)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/cli/setup.cfg b/cli/setup.cfg deleted file mode 100644 index e57d130..0000000 --- a/cli/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[wheel] -universal = True diff --git a/cli/setup.py b/cli/setup.py deleted file mode 100644 index 49b586c..0000000 --- a/cli/setup.py +++ /dev/null @@ -1,29 +0,0 @@ -from fluffy_cli import __version__ -from setuptools import find_packages -from setuptools import setup - - -def main(): - setup( - name='fluffy', - version=__version__, - author='Chris Kuehl', - author_email='ckuehl@ckuehl.me', - packages=find_packages(exclude=('test*',)), - install_requires=( - 'requests', - ), - classifiers=( - 'Programming Language :: Python :: 3', - ), - entry_points={ - 'console_scripts': [ - 'fput = fluffy_cli.main:upload_main', - 'fpb = fluffy_cli.main:paste_main', - ], - }, - ) - - -if __name__ == '__main__': - exit(main()) diff --git a/cli/tox.ini b/cli/tox.ini deleted file mode 100644 index d330257..0000000 --- a/cli/tox.ini +++ /dev/null @@ -1,8 +0,0 @@ -[flake8] -max-line-length = 119 - -[pep8] -# autopep8 will rewrite lines to be shorter, even though we raised the length -ignore = E501 - -; vim: et diff --git a/tests/cli/paste_test.py b/tests/cli/paste_test.py index 4e24ac0..d24381a 100644 --- a/tests/cli/paste_test.py +++ b/tests/cli/paste_test.py @@ -55,7 +55,7 @@ def test_paste_with_direct_link(running_server, tmpdir): @pytest.mark.usefixtures('cli_on_path') def test_paste_with_tee(running_server, tmpdir): - input_text = b'hello\nworld!' + input_text = b'hello\nworld!\n' output = subprocess.check_output( ('fpb', '--server', running_server['home'], '--tee'), input=input_text,