~nilium/urltool

2e30118a67e9c234d03932b32453827c23c8b97b — Noel Cower 5 years ago v0.1.0
Initial commit
4 files changed, 402 insertions(+), 0 deletions(-)

A COPYING
A README.adoc
A go.mod
A urltool.go
A  => COPYING +13 -0
@@ 1,13 @@
Copyright 2019 Noel Cower

Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.

THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

A  => README.adoc +71 -0
@@ 1,71 @@
= urltool(1)
Noel Cower
v0.1.0
:doctype: manpage
:manmanual: URLTOOL
:mansource: URLTOOL
:man-linkstyle: pass:[blue R < >]

== Name

urltool - modify URLs from the command line


== Synopsis

*urltool* [_-h|-help_] _<url>_... [_modifier_...]

== Description

urltool is a small tool to parse and optionally modify URLs from the command
line.

If urltool encounters an invalid URL, it will exit with status 1 after writing
an error message to standard error. urltool may print partial results in the
case of an error.

== Options
*-h*, *-help*::
	Print this help text.

== Modifiers

*-nh*[=_true|false_]::
	Disable URL parsing hacks (domain.tld and user:bar@domain.tld parsing).
*-s* _SCHEME_::
	Set the URL's scheme.
*-o* _OPAQUE_::
	set the URL's opaque value.
*-u* _USER_::
	Set the URL's username.
*-pw* _PASSWD_::
	Set the URL's password.
*-U*[=_true|false_]::
	Strip user info from the URL.
*-H* _HOST_::
	Set the URL's host.
*-P* _PORT_::
	Change the URL's host port (after taking the host from -H).
*-p* _PATH_::
	Set the URL's path (or join to it).
*-j*[=_true|false_]::
	Force joining the URL's path instead of setting it when relative.
*-fq*[=_true|false_]::
	Force a '?' to appear in the URL.
*-sq*[=_true|false_]::
	Strip query string before appending to it.
*-q* _K=V_::
	Append a ?K=V value to the query string. May be repeated. If no '='
	is found, an empty ?K= is added.
*-f* _FRAGMENT_::
	Set the URL's #fragment.
*-r* _URI_::
	Parse a URL relative to the input URL and use the result (after all
	other modifiers).

== Reporting Issues
If you encounter a bug in urltool, or want to request a feature or something
else, please open an issue on the project website if one doesn’t already exist:
<https://github.com/nilium/urltool>.

You can also submit pull requests through the project site.

A  => go.mod +3 -0
@@ 1,3 @@
module go.spiff.io/urltool

go 1.12

A  => urltool.go +315 -0
@@ 1,315 @@
// Command urltool manipulates URLs on the command line and prints the updated URL.
// See its usage (-h, -help) for more information.
package main

import (
	"bufio"
	"errors"
	"flag"
	"fmt"
	"net"
	"net/url"
	"os"
	"path"
	"strconv"
	"strings"
)

func usage() {
	fmt.Fprint(os.Stderr, `Usage: urltool [-h|-help] [URL...] [MODIFIERS]

Modify one or more URLs and print the results.

Options:
  -h, -help
    Print this help text.

Modifiers:
  -nh
    Disable URL parsing hacks (domain.tld and user:bar@domain.tld parsing).
  -s SCHEME
    Set the URL's scheme.
  -o OPAQUE
    set the URL's opaque value.
  -u USER
    Set the URL's username.
  -pw PASSWD
    Set the URL's password.
  -U[=true|false]
    Strip user info from the URL.
  -H HOST
    Set the URL's host.
  -P PORT
    Change the URL's host port (after taking the host from -H).
  -p PATH
    Set the URL's path (or join to it).
  -j[=true|false]
    Force joining the URL's path instead of setting it when relative.
  -fq[=true|false]
    Force a ? to appear in the URL.
  -sq[=true|false]
    Strip query string before appending to it.
  -q K=V
    Append a ?K=V value to the query string. May be repeated. If no '='
    is found, an empty ?K= is added.
  -f
    Set the URL's #fragment.
  -r
    Parse a URL relative to the input URL and use the result (after all
    other modifiers).
`)
	os.Exit(2)
}

func main() {
	code := 0
	defer func() {
		if rc := recover(); rc != nil {
			panic(rc)
		}
		os.Exit(code)
	}()

	out := bufio.NewWriter(os.Stdout)
	defer out.Flush()
	if isTTY() {
		defer func() { _, _ = out.WriteString("\n") }()
	}

	argv := os.Args[1:]
	if len(argv) == 0 {
		usage()
	}

	newline := ""
	for len(argv) > 0 {
		urls, rest, err := parseArgs(argv)
		if err != nil {
			fmt.Fprintln(os.Stderr, err)
			code = 1
		}

		for _, u := range urls {
			_, _ = out.WriteString(newline)
			newline = "\n"
			_, _ = out.WriteString(u.String())
		}

		argv = rest
	}
}

func parseArgs(args []string) (urls []*url.URL, rest []string, err error) {
	for len(args) > 0 && !strings.HasPrefix(args[0], "-") {
		us := args[0]
		u, err := url.Parse(us)
		if err != nil {
			return nil, nil, fmt.Errorf("unable to parse URL %q: %v", us, err)
		}
		urls = append(urls, u)
		args = args[1:]
	}

	var (
		nohacks       bool
		scheme        SetString
		opaque        SetString
		username      SetString
		password      SetString
		stripUser     bool
		host          SetString
		port          SetString
		newPath       SetString
		joinPath      bool
		forceQuery    bool
		stripQuery    bool
		query         queryArgs
		fragment      SetString
		parseRelative SetString
	)

	f := flag.NewFlagSet("urltool", flag.ExitOnError)
	f.Usage = usage
	f.BoolVar(&nohacks, "nh", false, "disable URL parsing hacks (domain.tld and user:bar@domain.tld parsing)")
	f.Var(&scheme, "s", "set the URL's scheme")
	f.Var(&opaque, "o", "set the URL's opaque value")
	f.Var(&username, "u", "set the URL's username")
	f.Var(&password, "pw", "set the URL's password")
	f.BoolVar(&stripUser, "U", false, "strip user info from the URL")
	f.Var(&host, "H", "set the URL's host")
	f.Var(&port, "P", "change the URL's host port (after taking the host from -H)")
	f.Var(&newPath, "p", "set the URL's path (or join to it)")
	f.BoolVar(&joinPath, "j", false, "force joining the URL's path instead of setting it when relative")
	f.BoolVar(&forceQuery, "fq", false, "force a ? to appear in the URL")
	f.BoolVar(&stripQuery, "sq", false, "strip query string before appending to it")
	f.Var(&query, "q", "append a ?K=V value to the query string")
	f.Var(&fragment, "f", "set the URL's #fragment")
	f.Var(&parseRelative, "r", "parse a `URL` relative to the input URL and use the result")
	if err := f.Parse(args); err != nil {
		return nil, nil, err
	}

	// Wait until here to check how many URLs there are, since the user might be passing -h or
	// -help.
	if len(urls) == 0 {
		return nil, nil, errors.New("no URLs given")
	}

	for i, u := range urls {
		if nohacks {
			// Skip the following URL hacks
		} else if at := strings.IndexByte(u.Opaque, '@'); u.Scheme != "" && at != -1 && u.Host == "" && u.Path == "" && u.User == nil {
			// Try to account for user:pass@domain style URLs
			user := u.Scheme
			pass := u.Opaque[:at]
			u.Host = u.Opaque[at+1:]
			if sep := strings.IndexByte(u.Host, '/'); sep != -1 {
				u.Host, u.Path = u.Host[:sep], u.Host[sep:]
			}
			u.User = url.UserPassword(user, pass)
			u.Opaque = ""
		} else if u.Scheme == "" && u.Host == "" && u.Path != "" {
			// Try to account for "foobar.com" as a URL, since that's technically a path
			if sep := strings.IndexByte(u.Path, '/'); sep != -1 {
				u.Host, u.Path = u.Path[:sep], u.Path[sep:] // Keep leading slash
			} else {
				u.Host, u.Path = u.Path, ""
			}
		}

		// Scheme
		if scheme.IsSet {
			u.Scheme = scheme.Str
		}

		// Opaque
		if opaque.IsSet {
			u.Opaque = opaque.Str
		}

		// User / password
		if stripUser {
			u.User = nil
		}
		user := u.User.Username()    // nil-safe
		pass, _ := u.User.Password() // nil-safe
		if username.IsSet {
			user = username.Str
		}
		if password.IsSet {
			pass = password.Str
		}
		if user != "" || pass != "" {
			u.User = url.UserPassword(user, pass)
		}

		// Hostname
		if host.IsSet {
			u.Host = host.Str
		}

		// Host port
		if port.IsSet {
			if _, err := strconv.ParseUint(port.Str, 10, 64); err != nil {
				return nil, nil, fmt.Errorf("invalid port number %q", port.Str)
			}
			h, _, err := net.SplitHostPort(u.Host)
			if err != nil {
				h = u.Host
			}
			u.Host = net.JoinHostPort(h, port.Str)
		}

		// Path
		if newPath.IsSet {
			if joinPath || !path.IsAbs(newPath.Str) {
				if u.Path == "" {
					u.Path = "/"
				}
				u.Path = path.Join(u.Path, newPath.Str)
			} else {
				u.Path = newPath.Str
			}

			u.Path = path.Clean(u.Path)
			if strings.HasPrefix(u.Path, "/../") {
				u.Path = "/"
			}
		}

		// Query string
		u.ForceQuery = forceQuery
		if stripQuery {
			u.RawQuery = ""
		}
		q := u.Query()
		for k, v := range query {
			q[k] = append(q[k], v...)
		}
		if len(q) != 0 {
			u.RawQuery = q.Encode()
		}

		// Fragment
		if fragment.IsSet {
			u.Fragment = fragment.Str
		}

		// Relative URL
		if parseRelative.IsSet {
			r, err := u.Parse(parseRelative.Str)
			if err != nil {
				return nil, nil, fmt.Errorf("unable to parse %q relative to %q: %v", r, u, err)
			}
			u = r
		}

		urls[i] = u
	}

	return urls, f.Args(), nil
}

type queryArgs url.Values

func (q *queryArgs) Set(s string) error {
	if *q == nil {
		*q = queryArgs{}
	}
	m := *q
	eq := strings.IndexByte(s, '=')
	if eq == -1 {
		m[s] = append(m[s], "")
		return nil
	}
	k, v := s[:eq], s[eq+1:]
	m[k] = append(m[k], v)
	return nil
}

func (q queryArgs) String() string {
	return "?K=V"
}

type SetString struct {
	IsSet bool
	Str   string
}

func (s *SetString) Set(v string) error {
	s.IsSet, s.Str = true, v
	return nil
}

func (s SetString) String() string {
	return s.Str
}

// isTTY attempts to determine whether the current stdout refers to a terminal.
func isTTY() bool {
	fi, err := os.Stdout.Stat()
	if err != nil {
		return false // Assume it's not a TTY
	}
	return (fi.Mode() & os.ModeNamedPipe) != os.ModeNamedPipe
}