initial commit before refactor switch
This commit is contained in:
88
server/consts.go
Normal file
88
server/consts.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
`embed`
|
||||
`encoding/json`
|
||||
`encoding/xml`
|
||||
`html/template`
|
||||
`os`
|
||||
`syscall`
|
||||
|
||||
sysdUtil `github.com/coreos/go-systemd/util`
|
||||
`github.com/goccy/go-yaml`
|
||||
)
|
||||
|
||||
const (
|
||||
convertTag string = "uaField"
|
||||
prettyTag string = "renderName"
|
||||
baseTitle string = "r00t^2 Client Info Revealer"
|
||||
titleSep string = " || "
|
||||
xmlHdrElem string = "header"
|
||||
xmlHdrElemName string = "name"
|
||||
xmlHdrVal string = "value"
|
||||
nilUaFieldStr string = "(N/A)"
|
||||
trueUaFieldStr string = "Yes"
|
||||
falseUaFieldStr string = "No"
|
||||
dfltIndent string = " "
|
||||
httpRealHdr string = "X-ClientInfo-RealIP"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed "tpl"
|
||||
tplDir embed.FS
|
||||
tpl *template.Template = template.Must(
|
||||
template.New("").
|
||||
Funcs(
|
||||
template.FuncMap{
|
||||
"getTitle": getTitle,
|
||||
},
|
||||
).ParseFS(tplDir, "tpl/*.tpl"),
|
||||
)
|
||||
)
|
||||
|
||||
// Signal traps
|
||||
var (
|
||||
stopSigs []os.Signal = []os.Signal{
|
||||
syscall.SIGQUIT,
|
||||
os.Interrupt,
|
||||
syscall.SIGTERM,
|
||||
}
|
||||
reloadSigs []os.Signal = []os.Signal{
|
||||
syscall.SIGHUP,
|
||||
// We also add stopSigs so we trigger the Reload loop to close. TODO.
|
||||
syscall.SIGQUIT,
|
||||
os.Interrupt,
|
||||
syscall.SIGTERM,
|
||||
}
|
||||
isSystemd bool = sysdUtil.IsRunningSystemd()
|
||||
)
|
||||
|
||||
// media/MIME types
|
||||
const (
|
||||
mediaJSON string = "application/json"
|
||||
mediaXML string = "application/xml"
|
||||
mediaYAML string = "application/yaml"
|
||||
mediaHTML string = "text/html"
|
||||
// TODO: plain/text? CSV? TOML?
|
||||
)
|
||||
|
||||
var (
|
||||
// mediaNoIndent covers everything (except HTML).
|
||||
mediaNoIndent map[string]func(obj any) (b []byte, err error) = map[string]func(obj any) (b []byte, err error){
|
||||
mediaJSON: json.Marshal,
|
||||
mediaXML: xml.Marshal,
|
||||
mediaYAML: yaml.Marshal,
|
||||
// HTML is handled explicitly.
|
||||
}
|
||||
// mediaIndent only contains MIME types that support configured indents.
|
||||
mediaIndent map[string]func(obj any, pfx string, indent string) (b []byte, err error) = map[string]func(obj any, pfx string, indent string) (b []byte, err error){
|
||||
mediaJSON: json.MarshalIndent,
|
||||
mediaXML: xml.MarshalIndent,
|
||||
}
|
||||
okAcceptMime []string = []string{
|
||||
mediaJSON,
|
||||
mediaXML,
|
||||
mediaYAML,
|
||||
mediaHTML,
|
||||
}
|
||||
)
|
||||
17
server/errs.go
Normal file
17
server/errs.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
`errors`
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEmptyUA error = errors.New("empty user agent string")
|
||||
ErrIncompatFieldType error = errors.New("a field type was passed that is incompatible with the target type")
|
||||
ErrInvalidAccept error = errors.New("an Accept header was encountered that does not conform to RFC 9110§12.5.1/IANA format")
|
||||
ErrInvalidScheme error = errors.New("invalid scheme for listener; must be 'unix', 'tcp', or 'http'")
|
||||
ErrNoArgs error = errors.New("no args.Args passed to server creation")
|
||||
ErrPtrNeeded error = errors.New("structs passed to reflection must be pointers")
|
||||
ErrStructNeeded error = errors.New("pointers passed to reflection must point to structs")
|
||||
ErrUnhandledField error = errors.New("unhandled field type passed to reflection")
|
||||
ErrUnsupportedMIME error = errors.New("unsupported MIME type(s)")
|
||||
)
|
||||
430
server/funcs.go
Normal file
430
server/funcs.go
Normal file
@@ -0,0 +1,430 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
`fmt`
|
||||
`net`
|
||||
`net/http`
|
||||
`net/url`
|
||||
`os`
|
||||
`path/filepath`
|
||||
`reflect`
|
||||
`sort`
|
||||
`strings`
|
||||
|
||||
`github.com/mileusna/useragent`
|
||||
`r00t2.io/clientinfo/args`
|
||||
`r00t2.io/goutils/logging`
|
||||
`r00t2.io/goutils/multierr`
|
||||
`r00t2.io/sysutils/paths`
|
||||
)
|
||||
|
||||
// NewClient returns a R00tClient from a UA string.
|
||||
func NewClient(uaStr string) (r *R00tClient, err error) {
|
||||
|
||||
var newR R00tClient
|
||||
var ua useragent.UserAgent
|
||||
|
||||
if strings.TrimSpace(uaStr) == "" {
|
||||
err = ErrEmptyUA
|
||||
return
|
||||
}
|
||||
|
||||
ua = useragent.Parse(uaStr)
|
||||
|
||||
if err = reflectClient(&ua, &newR); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
newR.ua = &ua
|
||||
|
||||
r = &newR
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// NewServer returns a Server ready to use. Be sure to call Close to free up resources when done.
|
||||
func NewServer(log logging.Logger, cliArgs *args.Args) (srv *Server, err error) {
|
||||
|
||||
var s Server
|
||||
var udsSockPerms args.UdsPerms
|
||||
|
||||
if log == nil {
|
||||
log = &logging.NullLogger{}
|
||||
}
|
||||
if cliArgs == nil {
|
||||
err = ErrNoArgs
|
||||
log.Err("server.NewServer: Received error creating server: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
s = Server{
|
||||
log: log,
|
||||
args: cliArgs,
|
||||
mux: http.NewServeMux(),
|
||||
sock: nil,
|
||||
reloadChan: make(chan os.Signal),
|
||||
stopChan: make(chan os.Signal),
|
||||
}
|
||||
|
||||
s.mux.HandleFunc("/", s.handleDefault)
|
||||
s.mux.HandleFunc("/about", s.handleAbout)
|
||||
s.mux.HandleFunc("/about.html", s.handleAbout)
|
||||
s.mux.HandleFunc("/usage", s.handleUsage)
|
||||
s.mux.HandleFunc("/usage.html", s.handleUsage)
|
||||
s.mux.HandleFunc("/favicon.ico", s.explicit404)
|
||||
|
||||
if s.listenUri, err = url.Parse(cliArgs.Listen.Listen); err != nil {
|
||||
s.log.Err("server.NewServer: Failed to parse listener URI: %v", err)
|
||||
return
|
||||
}
|
||||
s.listenUri.Scheme = strings.ToLower(s.listenUri.Scheme)
|
||||
|
||||
switch s.listenUri.Scheme {
|
||||
case "unix":
|
||||
if udsSockPerms, err = cliArgs.ModesAndOwners(); err != nil {
|
||||
s.log.Err("server.NewServer: Failed to parse unix socket permissions: %v", err)
|
||||
return
|
||||
}
|
||||
if err = paths.RealPath(&s.listenUri.Path); err != nil {
|
||||
s.log.Err("server.NewServer: Failed to canonize/resolve socket path '%s': %v", s.listenUri.Path, err)
|
||||
return
|
||||
}
|
||||
// Cleanup any stale socket.
|
||||
if err = s.cleanup(true); err != nil {
|
||||
s.log.Err("server.NewServer: Failed to cleanup for 'unix' listener: %v", err)
|
||||
return
|
||||
}
|
||||
if err = os.MkdirAll(filepath.Dir(s.listenUri.Path), udsSockPerms.DMode); err != nil {
|
||||
s.log.Err("server.NewServer: Received error creating socket directory '%s': %v", filepath.Dir(s.listenUri.Path), err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = os.Chmod(filepath.Dir(s.listenUri.Path), udsSockPerms.DMode); err != nil {
|
||||
s.log.Err("server.NewServer: Received error chmodding socket directory '%s': %v", filepath.Dir(s.listenUri.Path), err)
|
||||
return
|
||||
}
|
||||
if err = os.Chown(filepath.Dir(s.listenUri.Path), udsSockPerms.UID, udsSockPerms.DGID); err != nil {
|
||||
s.log.Err("server.NewServer: Received error chowning socket directory '%s': %v", filepath.Dir(s.listenUri.Path), err)
|
||||
return
|
||||
}
|
||||
if s.listenUri, err = url.Parse(
|
||||
fmt.Sprintf(
|
||||
"%s://%s",
|
||||
s.listenUri.Scheme, s.listenUri.Path,
|
||||
),
|
||||
); err != nil {
|
||||
s.log.Err("server.NewServer: Failed to re-parse listener URI: %v", err)
|
||||
return
|
||||
}
|
||||
if s.sock, err = net.Listen("unix", s.listenUri.Path); err != nil {
|
||||
s.log.Err("server.NewServer: Failed to open socket on '%s': %v", s.listenUri.Path, err)
|
||||
}
|
||||
if err = os.Chmod(s.listenUri.Path, udsSockPerms.FMode); err != nil {
|
||||
s.log.Err("server.NewServer: Received error chmodding socket '%s': %v", filepath.Dir(s.listenUri.Path), err)
|
||||
return
|
||||
}
|
||||
if err = os.Chown(s.listenUri.Path, udsSockPerms.UID, udsSockPerms.FGID); err != nil {
|
||||
s.log.Err("server.NewServer: Received error chowning socket '%s': %v", filepath.Dir(s.listenUri.Path), err)
|
||||
return
|
||||
}
|
||||
case "http", "tcp":
|
||||
s.isHttp = s.listenUri.Scheme == "http"
|
||||
if err = s.cleanup(true); err != nil {
|
||||
s.log.Err("server.NewServer: Failed to cleanup for '%s' listener: %v", strings.ToUpper(s.listenUri.Scheme), err)
|
||||
return
|
||||
}
|
||||
if s.listenUri, err = url.Parse(
|
||||
fmt.Sprintf(
|
||||
"%s://%s%s",
|
||||
s.listenUri.Scheme, s.listenUri.Host, s.listenUri.Path,
|
||||
),
|
||||
); err != nil {
|
||||
s.log.Err("server.NewServer: Failed to re-parse listener URI: %v", err)
|
||||
return
|
||||
}
|
||||
if s.sock, err = net.Listen("tcp", s.listenUri.Host); err != nil {
|
||||
s.log.Err("server.NewServer: Failed to open %s socket on '%s': %v", strings.ToUpper(s.listenUri.Scheme), s.listenUri.Host, err)
|
||||
return
|
||||
}
|
||||
default:
|
||||
s.log.Err("server.NewServer: Unsupported scheme: %v", s.listenUri.Scheme)
|
||||
err = ErrInvalidScheme
|
||||
return
|
||||
}
|
||||
cliArgs.Listen.Listen = s.listenUri.String()
|
||||
|
||||
srv = &s
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
/*
|
||||
decideParseAccept takes the slice returned from parseAccept, runs parseAccept on it,
|
||||
and chooses based on what MIME types are supported by this program.
|
||||
err will be an ErrUnsupportedMIME if no supported MIME type is found.
|
||||
If parsed is nil or empty, format will be defFormat and err will be nil.
|
||||
*/
|
||||
func decideParseAccept(parsed []*parsedMIME, defFormat string) (format string, err error) {
|
||||
|
||||
var customFmtFound bool
|
||||
|
||||
if parsed == nil || len(parsed) == 0 {
|
||||
format = defFormat
|
||||
return
|
||||
}
|
||||
|
||||
for _, pf := range parsed {
|
||||
switch pf.MIME {
|
||||
case "*/*": // Client explicitly accept anything
|
||||
format = defFormat
|
||||
customFmtFound = true
|
||||
case "application/*": // Use JSON
|
||||
format = mediaJSON
|
||||
customFmtFound = true
|
||||
case "text/*": // Use HTML
|
||||
format = mediaHTML
|
||||
customFmtFound = true
|
||||
case mediaHTML, mediaJSON, mediaXML, mediaYAML:
|
||||
format = pf.MIME
|
||||
customFmtFound = true
|
||||
}
|
||||
if customFmtFound {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !customFmtFound {
|
||||
format = defFormat
|
||||
err = ErrUnsupportedMIME
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
/*
|
||||
reflectClient takes a src and dst and attempts to set/convert src to dst. It is *VERY STRICT*.
|
||||
It is expected that src does NOT use pointers.
|
||||
...This is pretty much just custom-made for converting a useragent.UserAgent to a R00tClient.
|
||||
Don't use it for anything else.
|
||||
*/
|
||||
func reflectClient(src, dst any) (err error) {
|
||||
|
||||
var dstField reflect.StructField
|
||||
var dstFieldVal reflect.Value
|
||||
var srcFieldVal reflect.Value
|
||||
var srcField string
|
||||
var ok bool
|
||||
var intVal *int
|
||||
var strVal *string
|
||||
var boolVal *bool
|
||||
var srcVal reflect.Value = reflect.ValueOf(src)
|
||||
var dstVal reflect.Value = reflect.ValueOf(dst)
|
||||
|
||||
// Both must be ptrs to a struct
|
||||
if srcVal.Kind() != reflect.Ptr || dstVal.Kind() != reflect.Ptr {
|
||||
err = ErrPtrNeeded
|
||||
return
|
||||
}
|
||||
|
||||
srcVal = srcVal.Elem()
|
||||
dstVal = dstVal.Elem()
|
||||
|
||||
/*
|
||||
Now that we have the underlying type/value of the ptr above,
|
||||
check for structs.
|
||||
*/
|
||||
if srcVal.Kind() != reflect.Struct || dstVal.Kind() != reflect.Struct {
|
||||
err = ErrStructNeeded
|
||||
return
|
||||
}
|
||||
|
||||
for i := 0; i < dstVal.NumField(); i++ {
|
||||
dstField = dstVal.Type().Field(i)
|
||||
dstFieldVal = dstVal.Field(i)
|
||||
|
||||
// Skip unexported
|
||||
if !dstFieldVal.CanSet() {
|
||||
continue
|
||||
}
|
||||
srcField = dstField.Tag.Get(convertTag)
|
||||
// Skip explicitly skipped (<convertTag>:"-")
|
||||
if srcField == "-" {
|
||||
continue
|
||||
}
|
||||
// If no explicit field name is present, set it to the dst field name.
|
||||
if _, ok = dstField.Tag.Lookup(convertTag); !ok {
|
||||
srcField = dstField.Name
|
||||
}
|
||||
// Get the value from src
|
||||
srcFieldVal = srcVal.FieldByName(srcField)
|
||||
// Skip invalid...
|
||||
if !srcFieldVal.IsValid() {
|
||||
continue
|
||||
}
|
||||
// And zero-value.
|
||||
if reflect.DeepEqual(srcFieldVal.Interface(), reflect.Zero(srcFieldVal.Type()).Interface()) {
|
||||
continue
|
||||
}
|
||||
// Structs need to recurse.
|
||||
if dstFieldVal.Kind() == reflect.Ptr && dstFieldVal.Type().Elem().Kind() == reflect.Struct {
|
||||
// Ensure we don't have a nil ptr
|
||||
if dstFieldVal.IsNil() {
|
||||
dstFieldVal.Set(reflect.New(dstFieldVal.Type().Elem()))
|
||||
}
|
||||
// And recurse into it.
|
||||
if err = reflectClient(srcFieldVal.Addr().Interface(), dstFieldVal.Interface()); err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Everything else gets assigned here.
|
||||
switch dstFieldVal.Kind() {
|
||||
case reflect.Bool:
|
||||
if srcFieldVal.Kind() == reflect.Bool {
|
||||
dstFieldVal.Set(reflect.ValueOf(srcFieldVal.Interface().(bool)))
|
||||
} else {
|
||||
err = ErrIncompatFieldType
|
||||
return
|
||||
}
|
||||
case reflect.String:
|
||||
if srcFieldVal.Kind() == reflect.String {
|
||||
dstFieldVal.Set(reflect.ValueOf(srcFieldVal.Interface().(string)))
|
||||
} else {
|
||||
err = ErrIncompatFieldType
|
||||
return
|
||||
}
|
||||
case reflect.Int:
|
||||
if srcFieldVal.Kind() == reflect.Int {
|
||||
dstFieldVal.Set(reflect.ValueOf(srcFieldVal.Interface().(int)))
|
||||
} else {
|
||||
err = ErrIncompatFieldType
|
||||
return
|
||||
}
|
||||
case reflect.Ptr:
|
||||
// Pointers to above
|
||||
switch dstFieldVal.Type().Elem().Kind() {
|
||||
case reflect.Bool:
|
||||
if srcFieldVal.Kind() == reflect.Bool {
|
||||
boolVal = new(bool)
|
||||
*boolVal = srcFieldVal.Interface().(bool)
|
||||
dstFieldVal.Set(reflect.ValueOf(boolVal))
|
||||
} else {
|
||||
err = ErrIncompatFieldType
|
||||
return
|
||||
}
|
||||
case reflect.String:
|
||||
if srcFieldVal.Kind() == reflect.String {
|
||||
strVal = new(string)
|
||||
*strVal = srcFieldVal.Interface().(string)
|
||||
dstFieldVal.Set(reflect.ValueOf(strVal))
|
||||
} else {
|
||||
err = ErrIncompatFieldType
|
||||
return
|
||||
}
|
||||
case reflect.Int:
|
||||
if srcFieldVal.Kind() == reflect.Int {
|
||||
intVal = new(int)
|
||||
*intVal = srcFieldVal.Interface().(int)
|
||||
dstFieldVal.Set(reflect.ValueOf(intVal))
|
||||
} else {
|
||||
err = ErrIncompatFieldType
|
||||
return
|
||||
}
|
||||
default:
|
||||
err = ErrUnhandledField
|
||||
return
|
||||
}
|
||||
default:
|
||||
err = ErrUnhandledField
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// parseAccept parses an Accept header as per RFC 9110 § 12.5.1.
|
||||
func parseAccept(hdrVal string) (parsed []*parsedMIME, err error) {
|
||||
|
||||
var mimes []string
|
||||
var parts []string
|
||||
var params []string
|
||||
var paramsLen int
|
||||
var kv []string
|
||||
var mt *parsedMIME
|
||||
var mErr *multierr.MultiError = multierr.NewMultiError(nil)
|
||||
|
||||
if hdrVal == "" {
|
||||
return
|
||||
}
|
||||
|
||||
mimes = strings.Split(hdrVal, ",")
|
||||
|
||||
for _, mime := range mimes {
|
||||
mt = &parsedMIME{
|
||||
MIME: "",
|
||||
Weight: 1.0, // between 0.0 and 1.0
|
||||
Params: nil,
|
||||
}
|
||||
mime = strings.TrimSpace(mime)
|
||||
// Split into []string{<type>[, <param>, ...]}
|
||||
parts = strings.Split(mime, ";")
|
||||
if parts == nil || len(parts) < 1 {
|
||||
mErr.AddError(ErrInvalidAccept)
|
||||
continue
|
||||
}
|
||||
if parts[0] == "" {
|
||||
mErr.AddError(ErrInvalidAccept)
|
||||
continue
|
||||
}
|
||||
if len(strings.Split(parts[0], "/")) != 2 {
|
||||
mErr.AddError(ErrInvalidAccept)
|
||||
continue
|
||||
}
|
||||
mt.MIME = strings.TrimSpace(parts[0])
|
||||
if len(parts) > 1 {
|
||||
// Parameters were provided. We don't really use them except `q`, but...
|
||||
params = parts[1:]
|
||||
paramsLen = len(params)
|
||||
for idx, param := range params {
|
||||
param = strings.TrimSpace(param)
|
||||
kv = strings.SplitN(param, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
mErr.AddError(ErrInvalidAccept)
|
||||
continue
|
||||
}
|
||||
if kv[0] == "q" && idx == paramsLen-1 {
|
||||
// It's the weight. RFC's pretty clear it's the last param.
|
||||
fmt.Sscanf(kv[1], "%f", &mt.Weight)
|
||||
if mt.Weight > 1.0 || mt.Weight < 0.0 {
|
||||
mErr.AddError(ErrInvalidAccept)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
if mt.Params == nil {
|
||||
mt.Params = make(map[string]string)
|
||||
}
|
||||
mt.Params[kv[0]] = kv[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
parsed = append(parsed, mt)
|
||||
}
|
||||
|
||||
// Now sort by weight (descending).
|
||||
sort.SliceStable(
|
||||
parsed,
|
||||
func(i, j int) (isBefore bool) {
|
||||
isBefore = parsed[i].Weight > parsed[j].Weight
|
||||
return
|
||||
},
|
||||
)
|
||||
|
||||
if !mErr.IsEmpty() {
|
||||
err = mErr
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
12
server/funcs_page.go
Normal file
12
server/funcs_page.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
`fmt`
|
||||
)
|
||||
|
||||
func (p *Page) RenderIP(indent uint) (s string) {
|
||||
|
||||
s = fmt.Sprintf("<a href=\"https://ipinfo.io/%s\">%s</a>", p.Info.IP.String(), p.Info.IP.String())
|
||||
|
||||
return
|
||||
}
|
||||
88
server/funcs_r00tclient.go
Normal file
88
server/funcs_r00tclient.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
`reflect`
|
||||
`strings`
|
||||
)
|
||||
|
||||
/*
|
||||
ToMap generates and returns a map representation of a R00tClient.
|
||||
|
||||
Keys by default use the YAML tag for the name.
|
||||
If they are specified with the tag `renderName:"-"`, they are skipped.
|
||||
If they are specified with the tag `renderName:"Foo"`, the string "Foo" will
|
||||
be used as the key instead.
|
||||
Only bools, strings, and pointers thereof are allowed.
|
||||
|
||||
m will never be nil, but may be empty.
|
||||
|
||||
Currently err will always be nil but is specified for future API compatibility.
|
||||
It should be handled by callers for future-proofing, as it may not always be nil
|
||||
in the future.
|
||||
*/
|
||||
func (r *R00tClient) ToMap() (m map[string]string, err error) {
|
||||
|
||||
var ok bool
|
||||
var tagVal string
|
||||
var field reflect.StructField
|
||||
var fieldVal reflect.Value
|
||||
var rootVal reflect.Value
|
||||
|
||||
m = make(map[string]string)
|
||||
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
rootVal = reflect.ValueOf(r).Elem()
|
||||
|
||||
for i := 0; i < rootVal.NumField(); i++ {
|
||||
field = rootVal.Type().Field(i)
|
||||
fieldVal = rootVal.Field(i)
|
||||
|
||||
// Only exported.
|
||||
if field.PkgPath != "" {
|
||||
continue
|
||||
}
|
||||
// Get the key name.
|
||||
tagVal = field.Tag.Get(prettyTag)
|
||||
if tagVal == "-" {
|
||||
continue
|
||||
}
|
||||
if _, ok = field.Tag.Lookup(prettyTag); !ok {
|
||||
tagVal = field.Tag.Get("yaml")
|
||||
if tagVal == "" || strings.HasPrefix(tagVal, "-") {
|
||||
// Use the field name itself. YOLO
|
||||
tagVal = field.Name
|
||||
} else {
|
||||
tagVal = strings.Split(tagVal, ",")[0]
|
||||
}
|
||||
}
|
||||
switch fieldVal.Kind() {
|
||||
case reflect.Bool:
|
||||
if fieldVal.Interface().(bool) {
|
||||
m[tagVal] = trueUaFieldStr
|
||||
} else {
|
||||
m[tagVal] = falseUaFieldStr
|
||||
}
|
||||
case reflect.String:
|
||||
m[tagVal] = fieldVal.String()
|
||||
case reflect.Ptr:
|
||||
if fieldVal.IsNil() {
|
||||
m[tagVal] = nilUaFieldStr
|
||||
} else {
|
||||
switch fieldVal.Type().Elem().Kind() {
|
||||
case reflect.Bool:
|
||||
if fieldVal.Elem().Bool() {
|
||||
m[tagVal] = trueUaFieldStr
|
||||
} else {
|
||||
m[tagVal] = falseUaFieldStr
|
||||
}
|
||||
case reflect.String:
|
||||
m[tagVal] = fieldVal.Elem().String()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
860
server/funcs_server.go
Normal file
860
server/funcs_server.go
Normal file
@@ -0,0 +1,860 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
`crypto/tls`
|
||||
`encoding/json`
|
||||
`encoding/xml`
|
||||
"errors"
|
||||
"fmt"
|
||||
`mime/multipart`
|
||||
"net"
|
||||
"net/http"
|
||||
`net/http/fcgi`
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
`syscall`
|
||||
|
||||
sysd "github.com/coreos/go-systemd/daemon"
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
`github.com/goccy/go-yaml`
|
||||
"r00t2.io/goutils/multierr"
|
||||
)
|
||||
|
||||
// Close cleanly closes any remnants of a Server. Stop should be used instead to cleanly shut down; this is a little more aggressive.
|
||||
func (s *Server) Close() (err error) {
|
||||
|
||||
s.log.Debug("server.Server.Close: Closing sockets.")
|
||||
|
||||
if err = s.cleanup(false); err != nil {
|
||||
s.log.Err("server.Server.Close: Received error closing sockets: %v", err)
|
||||
}
|
||||
|
||||
s.log.Debug("server.Server.Close: Sockets closed.")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
/*
|
||||
Run starts and runs the server. This process blocks and will shutdown on a systemd notify signal or kill signal.
|
||||
Non-HTML requests will be of type R00tInfo serialized to the requested MIME type.
|
||||
*/
|
||||
func (s *Server) Run() (err error) {
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var errChan chan error
|
||||
var mErr *multierr.MultiError = multierr.NewMultiError(nil)
|
||||
var numJobs int = 2 // sigs, listener
|
||||
|
||||
s.log.Debug("server.Server.Run: Starting server.")
|
||||
|
||||
signal.Notify(s.reloadChan, reloadSigs...)
|
||||
signal.Notify(s.stopChan, stopSigs...)
|
||||
s.doneChan = make(chan bool, 1)
|
||||
|
||||
errChan = make(chan error, numJobs)
|
||||
wg.Add(numJobs)
|
||||
|
||||
// sigs
|
||||
go func() {
|
||||
var sigErr error
|
||||
var sig os.Signal
|
||||
var smErr *multierr.MultiError = multierr.NewMultiError(nil)
|
||||
|
||||
defer wg.Done()
|
||||
|
||||
sigtrap:
|
||||
for !s.isStopping {
|
||||
if s.isStopping {
|
||||
break sigtrap
|
||||
}
|
||||
sig = <-s.reloadChan
|
||||
s.log.Debug("server.Server.Run: Recived signal %v (%#v): %v", sig, sig, sig.String())
|
||||
switch sig {
|
||||
case syscall.SIGHUP:
|
||||
s.log.Debug("server.Server.Run: Recived reload signal.")
|
||||
if s.isStopping {
|
||||
s.log.Debug("server.Server.Run: Server is stopping; abandoning reload.")
|
||||
if sigErr = s.Stop(); sigErr != nil {
|
||||
s.log.Err("server.Server.Run: Received error while stopping the server: %v", sigErr)
|
||||
sigErr = nil
|
||||
}
|
||||
} else {
|
||||
if sigErr = s.Reload(); sigErr != nil {
|
||||
s.log.Err("server.Server.Run: Received error while reloading the server: %v", sigErr)
|
||||
smErr.AddError(sigErr)
|
||||
sigErr = nil
|
||||
}
|
||||
break sigtrap
|
||||
}
|
||||
default:
|
||||
// Stop signal.
|
||||
s.log.Debug("server.Server.Run: Recived stop signal.")
|
||||
if sigErr = s.Stop(); sigErr != nil {
|
||||
s.log.Err("server.Server.Run: Received error while stopping the server: %v", sigErr)
|
||||
smErr.AddError(sigErr)
|
||||
sigErr = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !smErr.IsEmpty() {
|
||||
errChan <- smErr
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
// listener
|
||||
go func() {
|
||||
var lErr error
|
||||
|
||||
defer wg.Done()
|
||||
|
||||
if isSystemd {
|
||||
var supported bool
|
||||
|
||||
// https://www.freedesktop.org/software/systemd/man/sd_notify.html
|
||||
if supported, lErr = sysd.SdNotify(false, sysd.SdNotifyReady); lErr != nil {
|
||||
s.log.Err(
|
||||
"server.Server.Run: Error encountered when notifying systemd of changestate to READY (supported: %v): %v",
|
||||
supported, lErr,
|
||||
)
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
|
||||
switch s.listenUri.Scheme {
|
||||
case "unix", "tcp":
|
||||
if lErr = fcgi.Serve(s.sock, s.mux); lErr != nil {
|
||||
if errors.Is(lErr, net.ErrClosed) {
|
||||
lErr = nil
|
||||
} else {
|
||||
errChan <- lErr
|
||||
}
|
||||
return
|
||||
}
|
||||
case "http":
|
||||
if lErr = http.Serve(s.sock, s.mux); lErr != nil {
|
||||
if errors.Is(lErr, http.ErrServerClosed) || errors.Is(lErr, net.ErrClosed) {
|
||||
lErr = nil
|
||||
} else {
|
||||
errChan <- lErr
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(errChan)
|
||||
s.doneChan <- true
|
||||
}()
|
||||
|
||||
<-s.doneChan
|
||||
|
||||
for i := 0; i < numJobs; i++ {
|
||||
if err = <-errChan; err != nil {
|
||||
mErr.AddError(err)
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
|
||||
if !mErr.IsEmpty() {
|
||||
err = mErr
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Debug("server.Server.Run: Server shut down.")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Stop stops the server.
|
||||
func (s *Server) Stop() (err error) {
|
||||
|
||||
s.log.Debug("server.Server.Stop: Stopping server.")
|
||||
|
||||
s.isStopping = true
|
||||
|
||||
if isSystemd {
|
||||
// https://www.freedesktop.org/software/systemd/man/sd_notify.html
|
||||
if _, err = sysd.SdNotify(false, sysd.SdNotifyStopping); err != nil {
|
||||
s.log.Err("server.Server.stop: Received error notifying systemd of stop: %v", err)
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
|
||||
if err = s.Close(); err != nil {
|
||||
s.log.Err("server.Server.stop: Received error closing server connections: %v", err)
|
||||
err = nil
|
||||
}
|
||||
|
||||
s.log.Debug("server.Server.Stop: Server stopped.")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// cleanup cleans up remaining sockets, closes channels, etc.
|
||||
func (s *Server) cleanup(init bool) (err error) {
|
||||
|
||||
var mErr *multierr.MultiError = multierr.NewMultiError(nil)
|
||||
|
||||
s.log.Debug("server.Server.cleanup: Cleaning up sockets, etc.")
|
||||
|
||||
if s.sock != nil && !init {
|
||||
if err = s.sock.Close(); err != nil {
|
||||
s.log.Err("server.Server.cleanup: Received error closing socket: %v", err)
|
||||
mErr.AddError(err)
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
if s.listenUri.Scheme == "unix" {
|
||||
if err = os.Remove(s.listenUri.Path); err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
s.log.Err("server.Server.cleanup: Failed to remove UDS '%s': %v", s.listenUri.Path, err)
|
||||
mErr.AddError(err)
|
||||
}
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
|
||||
if !mErr.IsEmpty() {
|
||||
err = mErr
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Debug("server.Server.cleanup: Completed cleanup.")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) Reload() (err error) {
|
||||
|
||||
s.log.Debug("server.Server.Reload: Reload called, but nothing was done; this is a placeholder as there are no reload-associated operations assigned.")
|
||||
if isSystemd {
|
||||
// https://www.freedesktop.org/software/systemd/man/sd_notify.html
|
||||
if _, err = sysd.SdNotify(false, sysd.SdNotifyReloading); err != nil {
|
||||
s.log.Err("server.Server.Reload: Received error notifying systemd of reload: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// TODO?
|
||||
|
||||
if isSystemd {
|
||||
var supported bool
|
||||
|
||||
// https://www.freedesktop.org/software/systemd/man/sd_notify.html
|
||||
if supported, err = sysd.SdNotify(false, sysd.SdNotifyReady); err != nil {
|
||||
s.log.Err(
|
||||
"server.Server.Reload: Error encountered when notifying systemd of changestate to READY (supported: %v): %v",
|
||||
supported, err,
|
||||
)
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) explicit404(resp http.ResponseWriter, req *http.Request) {
|
||||
resp.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleDefault(resp http.ResponseWriter, req *http.Request) {
|
||||
|
||||
var vals url.Values
|
||||
var uaVals []string
|
||||
var doInclude bool
|
||||
var doIndent bool
|
||||
var err error
|
||||
var ok bool
|
||||
var b []byte
|
||||
var remAddrPort string
|
||||
var okMedia []string
|
||||
var nAP netip.AddrPort
|
||||
var parsedFmts []*parsedMIME
|
||||
var renderPage *Page = new(Page)
|
||||
var format string = mediaJSON
|
||||
var indent string = " "
|
||||
var client *R00tInfo = new(R00tInfo)
|
||||
|
||||
renderPage.RawIndent = " "
|
||||
renderPage.PageType = "index"
|
||||
|
||||
s.log.Debug("server.Server.handleDefault: Handling request:\n%s", spew.Sdump(req))
|
||||
|
||||
/*
|
||||
if req.URL != nil &&
|
||||
req.URL.Path != "" &&
|
||||
req.URL.Path != "/" &&
|
||||
req.URL.Path != "/index" &&
|
||||
req.URL.Path != "/index.html" {
|
||||
resp.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
*/
|
||||
|
||||
client.Req = req
|
||||
remAddrPort = req.RemoteAddr
|
||||
if s.isHttp && req.Header.Get(httpRealHdr) != "" {
|
||||
remAddrPort = req.Header.Get(httpRealHdr)
|
||||
req.Header.Del(httpRealHdr)
|
||||
}
|
||||
if remAddrPort != "" {
|
||||
if nAP, err = netip.ParseAddrPort(remAddrPort); err != nil {
|
||||
s.log.Err("server.Server.handleDefault: Failed to parse remote address '%s': %v", req.RemoteAddr, err)
|
||||
// Don't return an error in case we're doing weird things like direct socket clients.
|
||||
err = nil
|
||||
/*
|
||||
http.Error(resp, "ERROR: Failed to parse client address", http.StatusInternalServerError)
|
||||
return
|
||||
*/
|
||||
}
|
||||
client.IP = net.ParseIP(nAP.Addr().String())
|
||||
client.Port = nAP.Port()
|
||||
}
|
||||
client.Headers = XmlHeaders(req.Header)
|
||||
|
||||
uaVals = req.Header.Values("User-Agent")
|
||||
if uaVals != nil && len(uaVals) > 0 {
|
||||
client.Client = make([]*R00tClient, len(uaVals))
|
||||
for idx, ua := range uaVals {
|
||||
if client.Client[idx], err = NewClient(ua); err != nil {
|
||||
s.log.Err("server.Server.handleDefault: Failed to create client for '%s': %v", ua, err)
|
||||
http.Error(resp, fmt.Sprintf("ERROR: Failed to parse 'User-Agent' '%s'", ua), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if client.Client != nil && len(client.Client) > 0 {
|
||||
// Check the passed UAs for a browser. We then change the "default" format if so.
|
||||
for _, ua := range client.Client {
|
||||
if ua.IsMobile || ua.IsDesktop {
|
||||
format = mediaHTML
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
renderPage.Info = client
|
||||
|
||||
vals = req.URL.Query()
|
||||
|
||||
// Determine the format/MIME type of the response.
|
||||
if vals.Has("mime") {
|
||||
format = req.URL.Query().Get("mime")
|
||||
} else {
|
||||
if parsedFmts, err = parseAccept(strings.Join(req.Header.Values("Accept"), ",")); err != nil {
|
||||
s.log.Err("server.Server.handleDefault: Failed to parse Accept header: %v", err)
|
||||
http.Error(
|
||||
resp,
|
||||
"ERROR: Invalid 'Accept' header value; see RFC 9110 § 12.5.1 and https://www.iana.org/assignments/media-types/media-types.xhtml",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
return
|
||||
}
|
||||
if format, err = decideParseAccept(parsedFmts, mediaJSON); err != nil {
|
||||
if errors.Is(err, ErrUnsupportedMIME) {
|
||||
s.log.Err("server.Server.handleDefault: No supported MIME type found for '%s'.", req.RemoteAddr)
|
||||
for mt := range mediaNoIndent {
|
||||
okMedia = append(okMedia, mt)
|
||||
}
|
||||
req.Header.Set("Accept", strings.Join(okMedia, ", "))
|
||||
http.Error(resp, "ERROR: No supported MIME type specified; see 'Accept' header in response for valid types.", http.StatusNotAcceptable)
|
||||
return
|
||||
} else {
|
||||
s.log.Err("server.Server.handleDefault: Received unknown error choosing an Accept header for '%s': %v", req.RemoteAddr, err)
|
||||
http.Error(resp, "ERROR: Unknown error occurred when negotiationg MIME type.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
s.log.Debug("server.Server.handleDefault: Using format '%s' for '%s'", format, req.RemoteAddr)
|
||||
// If it's HTML and they want an include, that needs to be validated too.
|
||||
if format == mediaHTML && vals.Has("include") {
|
||||
doInclude = true
|
||||
if parsedFmts, err = parseAccept(strings.Join(vals["include"], ", ")); err != nil {
|
||||
s.log.Err("server.Server.handleDefault: Failed to parse include parameter: %v", err)
|
||||
http.Error(
|
||||
resp,
|
||||
"ERROR: Invalid 'include' parameter value; see RFC 9110 § 12.5.1 and https://www.iana.org/assignments/media-types/media-types.xhtml",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
return
|
||||
}
|
||||
if renderPage.RawFmt, err = decideParseAccept(parsedFmts, format); err != nil {
|
||||
if errors.Is(err, ErrUnsupportedMIME) {
|
||||
s.log.Err("server.Server.handleDefault: No supported MIME type found for '%#v' 'include'.", vals["include"], req.RemoteAddr)
|
||||
for mt := range mediaNoIndent {
|
||||
okMedia = append(okMedia, mt)
|
||||
}
|
||||
req.Header.Set("Accept", strings.Join(okMedia, ", "))
|
||||
http.Error(resp, "ERROR: No supported MIME type specified for 'include'; see 'Accept' header in response for valid types.", http.StatusNotAcceptable)
|
||||
return
|
||||
} else {
|
||||
s.log.Err("server.Server.handleDefault: Received unknown error choosing an include format for '%s': %v", req.RemoteAddr, err)
|
||||
http.Error(resp, "ERROR: Unknown error occurred when negotiationg MIME type.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
// The indentation is set below.
|
||||
}
|
||||
|
||||
// Determine indentation (if the format even supports it).
|
||||
if format == mediaHTML {
|
||||
if doInclude {
|
||||
if _, ok = mediaIndent[renderPage.RawFmt]; ok {
|
||||
doIndent = vals.Has("indent")
|
||||
if doIndent {
|
||||
if req.URL.Query().Get("indent") != "" {
|
||||
renderPage.RawIndent = req.URL.Query().Get("indent")
|
||||
renderPage.DoRawIndent = true
|
||||
}
|
||||
}
|
||||
} else if _, ok = mediaNoIndent[renderPage.RawFmt]; !ok {
|
||||
// It's not a supported MIME.
|
||||
s.log.Err("server.Server.handleDefault: Requested MIME type '%s' for '%s' unsupported.", renderPage.RawFmt, req.RemoteAddr)
|
||||
for mt := range mediaNoIndent {
|
||||
okMedia = append(okMedia, mt)
|
||||
}
|
||||
req.Header.Set("Accept", strings.Join(okMedia, ", "))
|
||||
http.Error(
|
||||
resp,
|
||||
fmt.Sprintf("ERROR: MIME type '%s' unsupported for 'include'; see Accept header in response for valid types.", renderPage.RawFmt),
|
||||
http.StatusNotAcceptable,
|
||||
)
|
||||
return
|
||||
} else {
|
||||
// This seems backwards, but "non-indented" formats actually need indenting enabled so their whitespace renders properly.
|
||||
renderPage.DoRawIndent = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if _, ok = mediaIndent[format]; ok {
|
||||
doIndent = vals.Has("indent")
|
||||
if doIndent {
|
||||
if req.URL.Query().Get("indent") != "" {
|
||||
indent = req.URL.Query().Get("indent")
|
||||
}
|
||||
}
|
||||
} else if _, ok = mediaNoIndent[format]; !ok {
|
||||
// It's not a supported MIME.
|
||||
s.log.Err("server.Server.handleDefault: Requested MIME type '%s' for '%s' unsupported.", format, req.RemoteAddr)
|
||||
for mt := range mediaNoIndent {
|
||||
okMedia = append(okMedia, mt)
|
||||
}
|
||||
req.Header.Set("Accept", strings.Join(okMedia, ", "))
|
||||
http.Error(resp, fmt.Sprintf("ERROR: MIME type '%s' unsupported; see Accept header in response for valid types.", format), http.StatusNotAcceptable)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Now render the response.
|
||||
if format == mediaHTML {
|
||||
// This gets special treatment since it's templated.
|
||||
resp.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if doInclude {
|
||||
renderPage.Raw = new(string)
|
||||
if doIndent {
|
||||
if b, err = mediaIndent[renderPage.RawFmt](client, "", renderPage.RawIndent); err != nil {
|
||||
s.log.Err("server.Server.handleDefault: Failed to render indented raw '%s' for '%s': %v", renderPage.RawFmt, req.RemoteAddr, err)
|
||||
http.Error(resp, fmt.Sprintf("ERROR: Failed to render 'include' '%s'", renderPage.RawFmt), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if b, err = mediaNoIndent[renderPage.RawFmt](client); err != nil {
|
||||
s.log.Err("server.Server.handleDefault: Failed to render raw '%s' for '%s': %v", renderPage.RawFmt, req.RemoteAddr, err)
|
||||
http.Error(resp, fmt.Sprintf("ERROR: Failed to render '%s'", renderPage.RawFmt), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
*renderPage.Raw = string(b)
|
||||
}
|
||||
if err = tpl.ExecuteTemplate(resp, "index", renderPage); err != nil {
|
||||
s.log.Err("server.Server.handleDefault: Failed to execute template for '%s': %v", req.RemoteAddr, err)
|
||||
http.Error(resp, "ERROR: Failed to render HTML", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
resp.Header().Set("Content-Type", format)
|
||||
if doIndent {
|
||||
// This was already filtered to valid specified MIME above.
|
||||
if b, err = mediaIndent[format](client, "", indent); err != nil {
|
||||
s.log.Err("server.Server.handleDefault: Failed to render indented '%s' for '%s': %v", format, req.RemoteAddr, err)
|
||||
http.Error(resp, fmt.Sprintf("ERROR: Failed to render '%s'", format), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if b, err = mediaNoIndent[format](client); err != nil {
|
||||
s.log.Err("server.Server.handleDefault: Failed to render '%s' for '%s': %v", format, req.RemoteAddr, err)
|
||||
http.Error(resp, fmt.Sprintf("ERROR: Failed to render '%s'", format), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err = resp.Write(b); err != nil {
|
||||
s.log.Err("server.Server.handleDefault: Failed to serve indented '%s' to '%s': %v", format, req.RemoteAddr, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.log.Debug("server.Server.handleDefault: Handled request:\n%s", spew.Sdump(req))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) handleDefaultNew(resp http.ResponseWriter, req *http.Request) {
|
||||
|
||||
var err error
|
||||
var page *Page
|
||||
var uas []string
|
||||
var reqdMimes []string
|
||||
var parsedUA *R00tClient
|
||||
var nAP netip.AddrPort
|
||||
var remAddrPort string
|
||||
var parsedFmts []*parsedMIME
|
||||
var renderer outerRenderer
|
||||
var includeFmt string
|
||||
var params url.Values = make(url.Values)
|
||||
var outerFmt string = mediaJSON
|
||||
|
||||
s.log.Debug("server.Server.handleDefault: Handling request:\n%s", spew.Sdump(req))
|
||||
|
||||
page = &Page{
|
||||
Info: &R00tInfo{
|
||||
Client: nil,
|
||||
IP: nil,
|
||||
Port: 0,
|
||||
Headers: XmlHeaders(req.Header),
|
||||
Req: req,
|
||||
},
|
||||
PageType: "index",
|
||||
Raw: nil,
|
||||
RawFmt: nil,
|
||||
Indent: "",
|
||||
DoIndent: false,
|
||||
}
|
||||
|
||||
// First the client info.
|
||||
remAddrPort = req.RemoteAddr
|
||||
if s.isHttp && req.Header.Get(httpRealHdr) != "" {
|
||||
// TODO: WHitelist explicit reverse proxy addr(s)?
|
||||
remAddrPort = req.Header.Get(httpRealHdr)
|
||||
req.Header.Del(httpRealHdr)
|
||||
}
|
||||
if remAddrPort != "" {
|
||||
if nAP, err = netip.ParseAddrPort(remAddrPort); err != nil {
|
||||
s.log.Warning("server.Server.handleDefault: Failed to parse remote address '%s': %v", remAddrPort, err)
|
||||
// Don't return an error in case we're doing weird things like direct socket clients.
|
||||
/*
|
||||
http.Error(resp, "ERROR: Failed to parse client address", http.StatusInternalServerError)
|
||||
return
|
||||
*/
|
||||
err = nil
|
||||
}
|
||||
page.Info.IP = net.ParseIP(nAP.Addr().String())
|
||||
page.Info.Port = nAP.Port()
|
||||
}
|
||||
if req.URL != nil {
|
||||
params = req.URL.Query()
|
||||
}
|
||||
uas = req.Header.Values("User-Agent")
|
||||
if uas != nil && len(uas) > 0 {
|
||||
page.Info.Client = make([]*R00tClient, 0, len(uas))
|
||||
for _, ua := range uas {
|
||||
if parsedUA, err = NewClient(ua); err != nil {
|
||||
s.log.Err("server.Server.handleDefault: Failed to create client for '%s': %v", ua, err)
|
||||
http.Error(resp, fmt.Sprintf("ERROR: Failed to parse 'User-Agent' '%s'", ua), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
page.Info.Client = append(page.Info.Client, parsedUA)
|
||||
}
|
||||
}
|
||||
if page.Info.Client != nil && len(page.Info.Client) > 0 {
|
||||
// Check the passed UAs for a browser. We then change the "default" format if so.
|
||||
for _, ua := range page.Info.Client {
|
||||
if ua.IsMobile || ua.IsDesktop {
|
||||
outerFmt = mediaHTML
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
At this point, the outer format *default*, client IP, client port, and client version (UA) is set.
|
||||
From here, we handle explicit content requests/overrides.
|
||||
*/
|
||||
// `Accept` request header...
|
||||
reqdMimes = req.Header.Values("Accept")
|
||||
if reqdMimes != nil && len(reqdMimes) > 0 {
|
||||
if parsedFmts, err = parseAccept(strings.Join(reqdMimes, ",")); err != nil {
|
||||
s.log.Err("server.Server.handleDefault: Failed to parse Accept header '%#v' for '%s': %v", reqdMimes, remAddrPort, err)
|
||||
resp.Header()["Accept"] = okAcceptMime
|
||||
http.Error(
|
||||
resp,
|
||||
"ERROR: Invalid 'Accept' header value; see RFC 9110 § 12.5.1, https://www.iana.org/assignments/media-types/media-types.xhtml, and this response's 'Accept' header.",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
return
|
||||
}
|
||||
if outerFmt, err = decideParseAccept(parsedFmts, outerFmt); err != nil {
|
||||
if errors.Is(err, ErrUnsupportedMIME) {
|
||||
s.log.Err("server.Server.handleDefault: No supported MIME type found for '%s' via '%#v'.", remAddrPort, reqdMimes)
|
||||
req.Header["Accept"] = okAcceptMime
|
||||
http.Error(resp, "ERROR: No supported MIME type specified via request 'Accept'; see 'Accept' header in response for valid types.", http.StatusNotAcceptable)
|
||||
return
|
||||
} else {
|
||||
s.log.Err("server.Server.handleDefault: Received unknown error choosing from Accept header for '%s': %v", remAddrPort, err)
|
||||
http.Error(resp, "ERROR: Unknown error occurred when negotiating MIME type.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// `mime` URL query parameter.
|
||||
if params.Has("mime") {
|
||||
if parsedFmts, err = parseAccept(strings.Join(params["mime"], ",")); err != nil {
|
||||
s.log.Err("server.Server.handleDefault: Failed to parse 'mime' URL parameter '%#v' for '%s': %v", params["mime"], remAddrPort, err)
|
||||
resp.Header()["Accept"] = okAcceptMime
|
||||
http.Error(
|
||||
resp,
|
||||
"ERROR: Invalid 'mime' URL parameter value; see RFC 9110 § 12.5.1, https://www.iana.org/assignments/media-types/media-types.xhtml, and this response's 'Accept' header.",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
return
|
||||
}
|
||||
if outerFmt, err = decideParseAccept(parsedFmts, outerFmt); err != nil {
|
||||
if errors.Is(err, ErrUnsupportedMIME) {
|
||||
s.log.Err("server.Server.handleDefault: No supported MIME type found for '%s' via '%#v'.", remAddrPort, params["mime"])
|
||||
req.Header["Accept"] = okAcceptMime
|
||||
http.Error(resp, "ERROR: No supported MIME type specified via URL parameter 'mime'; see 'Accept' header in response for valid types.", http.StatusNotAcceptable)
|
||||
return
|
||||
} else {
|
||||
s.log.Err("server.Server.handleDefault: Received unknown error choosing from 'mime' URL parameter for '%s': %v", remAddrPort, err)
|
||||
http.Error(resp, "ERROR: Unknown error occurred when negotiating MIME type.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// 'include' URL query parameter (only for text/html).
|
||||
if outerFmt == mediaHTML && params.Has("include") {
|
||||
if parsedFmts, err = parseAccept(strings.Join(params["include"], ",")); err != nil {
|
||||
s.log.Err("server.Server.handleDefault: Failed to parse 'include' URL parameter '%#v' for '%s': %v", params["include"], remAddrPort, err)
|
||||
resp.Header()["Accept"] = okAcceptMime
|
||||
http.Error(
|
||||
resp,
|
||||
"ERROR: Invalid 'include' URL parameter value; see RFC 9110 § 12.5.1, https://www.iana.org/assignments/media-types/media-types.xhtml, and this response's 'Accept' header.",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
return
|
||||
}
|
||||
if includeFmt, err = decideParseAccept(parsedFmts, includeFmt); err != nil {
|
||||
if errors.Is(err, ErrUnsupportedMIME) {
|
||||
s.log.Err("server.Server.handleDefault: No supported MIME type found for '%s' via '%#v'.", remAddrPort, params["include"])
|
||||
req.Header["Accept"] = okAcceptMime
|
||||
http.Error(resp, "ERROR: No supported MIME type specified via URL parameter 'include'; see 'Accept' header in response for valid types.", http.StatusNotAcceptable)
|
||||
return
|
||||
} else {
|
||||
s.log.Err("server.Server.handleDefault: Received unknown error choosing from 'include' URL parameter for '%s': %v", remAddrPort, err)
|
||||
http.Error(resp, "ERROR: Unknown error occurred when negotiating MIME type.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
if includeFmt != "" {
|
||||
page.RawFmt = new(string)
|
||||
*page.RawFmt = includeFmt
|
||||
}
|
||||
}
|
||||
// 'indent' URL query parameter.
|
||||
if params.Has("indent") {
|
||||
page.DoIndent = true
|
||||
if params.Get("indent") != "" {
|
||||
page.Indent = params.Get("indent")
|
||||
} else {
|
||||
page.Indent = dfltIndent
|
||||
}
|
||||
}
|
||||
|
||||
switch outerFmt {
|
||||
case mediaJSON:
|
||||
renderer = s.renderJSON
|
||||
case mediaHTML:
|
||||
renderer = s.renderHTML
|
||||
case mediaXML:
|
||||
renderer = s.renderXML
|
||||
case mediaYAML:
|
||||
renderer = s.renderYML
|
||||
default:
|
||||
s.log.Err("server.Server.handleDefault: Unknown output format '%s'", outerFmt)
|
||||
http.Error(resp, "ERROR: Unable to determine default renderer.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err = renderer(page, resp); err != nil {
|
||||
s.log.Err("server.Server.handleDefault: Failed to render request from '%s' as '%s': %v", remAddrPort, outerFmt, err)
|
||||
// The renderer handles the error-handling with the client.
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) handleAbout(resp http.ResponseWriter, req *http.Request) {
|
||||
|
||||
var err error
|
||||
var renderPage *Page = &Page{
|
||||
Info: &R00tInfo{
|
||||
Req: req,
|
||||
},
|
||||
PageType: "about",
|
||||
}
|
||||
|
||||
s.log.Debug("server.Server.handleAbout: Handling request:\n%s", spew.Sdump(req))
|
||||
|
||||
resp.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
if err = tpl.ExecuteTemplate(resp, "about", renderPage); err != nil {
|
||||
s.log.Err("server.Server.handleAbout: Failed to execute template for '%s': %v", req.RemoteAddr, err)
|
||||
http.Error(resp, "ERROR: Failed to render HTML", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Debug("server.Server.handleAbout: Handled request:\n%s", spew.Sdump(req))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) handleUsage(resp http.ResponseWriter, req *http.Request) {
|
||||
|
||||
var err error
|
||||
var renderPage *Page = &Page{
|
||||
Info: &R00tInfo{
|
||||
Req: req,
|
||||
},
|
||||
PageType: "usage",
|
||||
}
|
||||
|
||||
s.log.Debug("server.Server.handleUsage: Handling request:\n%s", spew.Sdump(req))
|
||||
|
||||
resp.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err = tpl.ExecuteTemplate(resp, "usage", renderPage); err != nil {
|
||||
s.log.Err("server.Server.handleAbout: Failed to execute template for '%s': %v", req.RemoteAddr, err)
|
||||
http.Error(resp, "ERROR: Failed to render HTML", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Debug("server.Server.handleUsage: Handled request:\n%s", spew.Sdump(req))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) renderJSON(page *Page, resp http.ResponseWriter) (err error) {
|
||||
|
||||
var b []byte
|
||||
|
||||
if page.DoIndent {
|
||||
if b, err = json.MarshalIndent(page.Info, "", page.Indent); err != nil {
|
||||
s.log.Err("server.Server.renderJSON: Failed to render to indented JSON: %v", err)
|
||||
http.Error(resp, "ERROR: Failed to render JSON", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if b, err = json.Marshal(page.Info); err != nil {
|
||||
s.log.Err("server.Server.renderJSON: Failed to render to JSON: %v", err)
|
||||
http.Error(resp, "ERROR: Failed to render JSON", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err = resp.Write(b); err != nil {
|
||||
s.log.Err("server.Server.renderJSON: Failed to send JSON: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) renderHTML(page *Page, resp http.ResponseWriter) (err error) {
|
||||
|
||||
var b []byte
|
||||
|
||||
if page.RawFmt != nil {
|
||||
switch *page.RawFmt {
|
||||
case mediaHTML:
|
||||
_ = "" // Explicit no-op; we're *serving* HTML.
|
||||
// Indentable
|
||||
case mediaJSON, mediaXML:
|
||||
if page.DoIndent {
|
||||
if b, err = mediaIndent[*page.RawFmt](page.Info, "", page.Indent); err != nil {
|
||||
s.log.Err("server.Server.renderHTML: Failed to render to indented include '%s': %v", *page.RawFmt, err)
|
||||
http.Error(resp, "ERROR: Failed to render include format", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if b, err = mediaNoIndent[*page.RawFmt](page.Indent); err != nil {
|
||||
s.log.Err("server.Server.renderHTML: Failed to render to include '%s': %v", *page.RawFmt, err)
|
||||
http.Error(resp, "ERROR: Failed to render include format", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Non-indentable
|
||||
case mediaYAML:
|
||||
if b, err = mediaNoIndent[*page.RawFmt](page.Info); err != nil {
|
||||
s.log.Err("server.Server.renderHTML: Failed to render to '%s': %v", *page.RawFmt, err)
|
||||
}
|
||||
}
|
||||
page.Raw = new(string)
|
||||
*page.Raw = string(b)
|
||||
}
|
||||
|
||||
if err = tpl.ExecuteTemplate(resp, "index", page); err != nil {
|
||||
s.log.Err("server.Server.renderHTML: Failed to render template: %v", err)
|
||||
http.Error(resp, "ERROR: Failed to render HTML", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) renderXML(page *Page, resp http.ResponseWriter) (err error) {
|
||||
|
||||
var b []byte
|
||||
|
||||
if page.DoIndent {
|
||||
if b, err = xml.MarshalIndent(page.Info, "", page.Indent); err != nil {
|
||||
s.log.Err("server.Server.renderXML: Failed to render to indented XML: %v", err)
|
||||
http.Error(resp, "ERROR: Failed to render XML", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if b, err = xml.Marshal(page.Info); err != nil {
|
||||
s.log.Err("server.Server.renderXML: Failed to render to XML: %v", err)
|
||||
http.Error(resp, "ERROR: Failed to render XML", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err = resp.Write(b); err != nil {
|
||||
s.log.Err("server.Server.renderXML: Failed to send XML: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) renderYML(page *Page, resp http.ResponseWriter) (err error) {
|
||||
|
||||
var b []byte
|
||||
|
||||
if b, err = yaml.Marshal(page.Info); err != nil {
|
||||
s.log.Err("server.Server.renderJSON: Failed to render to JSON: %v", err)
|
||||
http.Error(resp, "ERROR: Failed to render JSON", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = resp.Write(b); err != nil {
|
||||
s.log.Err("server.Server.renderJSON: Failed to send JSON: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
95
server/funcs_test.go
Normal file
95
server/funcs_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
`encoding/json`
|
||||
`encoding/xml`
|
||||
`fmt`
|
||||
"testing"
|
||||
|
||||
`github.com/davecgh/go-spew/spew`
|
||||
`github.com/goccy/go-yaml`
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
|
||||
var err error
|
||||
var b []byte
|
||||
var r *R00tClient
|
||||
|
||||
for _, s := range []string{
|
||||
"Mozilla/5.0 " +
|
||||
"(X11; Linux x86_64) " +
|
||||
"AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) " +
|
||||
"Chrome/131.0.0.0 " +
|
||||
"Safari/537.36", // Chrome
|
||||
"Mozilla/5.0 " +
|
||||
"(X11; Linux x86_64; rv:133.0) " +
|
||||
"Gecko/20100101 " +
|
||||
"Firefox/133.0", // Firefox
|
||||
"curl/8.11.0", // Curl
|
||||
"Wget/1.25.0", // Wget
|
||||
} {
|
||||
t.Logf("Raw UA: '%s'\n\n", s)
|
||||
if r, err = NewClient(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b, err = json.Marshal(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Println(string(b))
|
||||
t.Logf("R00tClient:\n%s\n\n\n", spew.Sdump(r))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitContent(t *testing.T) {
|
||||
|
||||
var b []byte
|
||||
var err error
|
||||
var r *R00tClient = &R00tClient{
|
||||
ClientVer: &Ver{
|
||||
Major: 1,
|
||||
Minor: 2,
|
||||
Patch: 3,
|
||||
},
|
||||
OSVer: &Ver{
|
||||
Major: 9,
|
||||
Minor: 8,
|
||||
Patch: 7,
|
||||
},
|
||||
URL: new(string),
|
||||
String: new(string),
|
||||
Name: new(string),
|
||||
ClientVerStr: new(string),
|
||||
OS: new(string),
|
||||
OsVerStr: new(string),
|
||||
Dev: new(string),
|
||||
IsMobile: false,
|
||||
IsTablet: false,
|
||||
IsDesktop: false,
|
||||
IsBot: false,
|
||||
}
|
||||
|
||||
*r.URL = "https://datatracker.ietf.org/doc/html/rfc2324.html"
|
||||
*r.String = "(COMPLETE USER AGENT STRING)"
|
||||
*r.Name = "coffee_pot"
|
||||
*r.ClientVerStr = "1.2.3"
|
||||
*r.OS = "JavaOS"
|
||||
*r.OsVerStr = "9.8.7"
|
||||
*r.Dev = "mocha-latte"
|
||||
|
||||
if b, err = json.MarshalIndent(r, "", " "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Println(string(b))
|
||||
|
||||
if b, err = xml.Marshal(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Println(string(b))
|
||||
|
||||
if b, err = yaml.Marshal(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Println(string(b))
|
||||
}
|
||||
18
server/funcs_tpl.go
Normal file
18
server/funcs_tpl.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
`fmt`
|
||||
`strings`
|
||||
)
|
||||
|
||||
func getTitle(subPage string) (title string) {
|
||||
|
||||
if subPage == "" || subPage == "index" {
|
||||
title = baseTitle
|
||||
return
|
||||
}
|
||||
|
||||
title = fmt.Sprintf("%s%s%s", baseTitle, titleSep, strings.ToTitle(subPage))
|
||||
|
||||
return
|
||||
}
|
||||
160
server/funcs_xmlheaders.go
Normal file
160
server/funcs_xmlheaders.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
`encoding/xml`
|
||||
`errors`
|
||||
`io`
|
||||
)
|
||||
|
||||
/*
|
||||
MarshalXML encodes an XmlHeaders as XML in the following format:
|
||||
|
||||
(<headers>)
|
||||
<header name="SomeHeader">
|
||||
<value>SomeValue</value>
|
||||
</header>
|
||||
<header name="SomeMultiValueHeader">
|
||||
<value>Foo</value>
|
||||
<value>Bar</value>
|
||||
</header>
|
||||
(</headers>)
|
||||
|
||||
For the above example, the field should be specified as `xml:"headers"`.
|
||||
However, the parent element name may be whatever you wish.
|
||||
*/
|
||||
func (x XmlHeaders) MarshalXML(e *xml.Encoder, start xml.StartElement) (err error) {
|
||||
|
||||
var curKey string
|
||||
var vals []string
|
||||
var val string
|
||||
var hdr xml.StartElement
|
||||
var child xml.StartElement
|
||||
// TODO: Does xml.EncodeElement properly escape?
|
||||
// var escKBuf *bytes.Buffer
|
||||
// var escVBuf *bytes.Buffer
|
||||
|
||||
// All values are []string, so we don't need any fancy parsing or switching or the like.
|
||||
// We do need to make sure we escape, though.
|
||||
|
||||
if err = e.EncodeToken(start); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if x != nil && len(x) > 0 {
|
||||
// escKBuf = new(bytes.Buffer)
|
||||
// escVBuf = new(bytes.Buffer)
|
||||
for curKey, vals = range x {
|
||||
// escKBuf.Reset()
|
||||
// if err = xml.EscapeText(escKBuf, []byte(curKey)); err != nil {
|
||||
// return
|
||||
// }
|
||||
hdr = xml.StartElement{
|
||||
Name: xml.Name{
|
||||
Local: xmlHdrElem,
|
||||
},
|
||||
Attr: []xml.Attr{
|
||||
xml.Attr{
|
||||
Name: xml.Name{
|
||||
Local: xmlHdrElemName,
|
||||
},
|
||||
// Value: escKBuf.String(),
|
||||
Value: curKey,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err = e.EncodeToken(hdr); err != nil {
|
||||
return
|
||||
}
|
||||
for _, val = range vals {
|
||||
// escVBuf.Reset()
|
||||
// if err = xml.EscapeText(escVBuf, []byte(val)); err != nil {
|
||||
// return
|
||||
// }
|
||||
child = xml.StartElement{
|
||||
Name: xml.Name{
|
||||
Local: xmlHdrVal,
|
||||
},
|
||||
}
|
||||
// if err = e.EncodeElement(escVBuf.String(), child); err != nil {
|
||||
if err = e.EncodeElement(val, child); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = e.EncodeToken(hdr.End()); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err = e.EncodeToken(start.End()); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// UnmarshalXML populates an XMLHeaders from an XML representation. See MarshalXML for example XML.
|
||||
func (x *XmlHeaders) UnmarshalXML(d *xml.Decoder, start xml.StartElement) (err error) {
|
||||
|
||||
var tok xml.Token
|
||||
var xm XmlHeaders
|
||||
var hdrNm string
|
||||
var vals []string
|
||||
var val *string
|
||||
var nameFound bool
|
||||
|
||||
for {
|
||||
if tok, err = d.Token(); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
err = nil
|
||||
break
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
switch elem := tok.(type) {
|
||||
case xml.StartElement:
|
||||
switch elem.Name.Local {
|
||||
case xmlHdrElem:
|
||||
nameFound = false
|
||||
vals = nil
|
||||
for _, a := range elem.Attr {
|
||||
if a.Name.Local == xmlHdrElemName {
|
||||
nameFound = true
|
||||
hdrNm = a.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
if !nameFound {
|
||||
continue
|
||||
}
|
||||
case xmlHdrVal:
|
||||
if !nameFound {
|
||||
continue
|
||||
}
|
||||
if vals == nil {
|
||||
vals = make([]string, 0, 1)
|
||||
}
|
||||
val = new(string)
|
||||
if err = d.DecodeElement(val, &elem); err != nil {
|
||||
return
|
||||
}
|
||||
vals = append(vals, *val)
|
||||
}
|
||||
case xml.EndElement:
|
||||
if elem.Name.Local != xmlHdrElem {
|
||||
continue
|
||||
}
|
||||
if xm == nil {
|
||||
xm = make(XmlHeaders)
|
||||
}
|
||||
xm[hdrNm] = vals
|
||||
}
|
||||
}
|
||||
|
||||
if xm != nil {
|
||||
*x = xm
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
45
server/tpl/about.html.tpl
Normal file
45
server/tpl/about.html.tpl
Normal file
@@ -0,0 +1,45 @@
|
||||
{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}}
|
||||
{{- define "about" }}
|
||||
{{- $page := . -}}
|
||||
{{- $linkico := "🔗" }}
|
||||
{{ template "meta.top" $page }}
|
||||
<div class="jumbotron">
|
||||
<h1>About</h1>
|
||||
</div>
|
||||
<p>
|
||||
This is a tool to reveal certain information about your connection that the server sees.
|
||||
Note that all of this information you see is <i>sent by your client</i>;
|
||||
there was no probing/scanning or the like done from the server this site is hosted on.
|
||||
</p>
|
||||
<p>
|
||||
If you don't like this info being available to server administrators of the websites
|
||||
you visit you may want to consider:
|
||||
<ul>
|
||||
<li><a href="https://www.torproject.org/">hiding your client IP address</a></li>
|
||||
<li>
|
||||
<a href="https://panopticlick.eff.org/self-defense">hiding your browser's metadata, which can be done via browser plugins such as:</a>
|
||||
<ul>
|
||||
<li><a href="https://www.eff.org/privacybadger">Privacy Badger</a></li>
|
||||
<li><a href="https://addons.mozilla.org/en-US/firefox/addon/modify-headers/">Modify Headers</a></li>
|
||||
<li><a href="https://www.requestly.in/">Requestly</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
There are, of course, many other plugins/methods but as always, due diligence is required when finding the right plugin for you.
|
||||
Be sure to read multiple reviews.
|
||||
Some plugins/extensions even disguise your browser as an entirely different operating system, OS version, etc.
|
||||
Feel free to check back on this site after enabling them to test! (You may need to reset your browser's cache.)
|
||||
</p>
|
||||
<p>
|
||||
If you would like to view the <i>server</i> headers, then you can:
|
||||
<ul>
|
||||
<li>use a service such as <a href="https://securityheaders.io">SecurityHeaders.io</a></li>
|
||||
<li>use the <code>--head</code> (<code>-i</code>, <code>-X HEAD</code>, <code>--request HEAD</code>, etc.; all do the same thing) argument to <b><code>curl</code></b></li>
|
||||
<li>use the <code>-v</code> (<code>--verbose</code>) argument to <b><code>curl</code></b></li>
|
||||
<li>use your browser's built-in developer console (<a href="https://firefox-source-docs.mozilla.org/devtools-user/">on Firefox</a>, <a href="https://developer.chrome.com/docs/devtools/open">on Chrome and Chrome-based browsers</a>)</li>
|
||||
</ul>
|
||||
There are additionally some extensions/plugins that offer this in a directly-accessible button on the toolbar.
|
||||
</p>
|
||||
<br />
|
||||
{{- template "meta.bottom" $page }}
|
||||
{{- end }}
|
||||
14
server/tpl/index.html.tpl
Normal file
14
server/tpl/index.html.tpl
Normal file
@@ -0,0 +1,14 @@
|
||||
{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}}
|
||||
{{- define "index" }}
|
||||
{{- $page := . -}}
|
||||
{{- $linkico := "🔗" }}
|
||||
{{- template "meta.top" $page }}
|
||||
<div class="jumbotron">
|
||||
<h1>Client Info Revealer</h1>
|
||||
<p class="lead">A tool to reveal client-identifying data sent to webservers</p>
|
||||
</div>
|
||||
<div>
|
||||
{{- template "meta.info" $page }}
|
||||
</div>
|
||||
{{- template "meta.bottom" $page }}
|
||||
{{- end }}
|
||||
11
server/tpl/meta.bottom.html.tpl
Normal file
11
server/tpl/meta.bottom.html.tpl
Normal file
@@ -0,0 +1,11 @@
|
||||
{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}}
|
||||
{{- define "meta.bottom" -}}
|
||||
{{- $page := . -}}
|
||||
{{- $linkico := "🔗" }}
|
||||
<footer class="footer">
|
||||
<p><sub>See <a href="https://pkg.go.dev/r00t2.io/clientinfo">https://pkg.go.dev/r00t2.io/clientinfo</a> for more information on this program.</sub></p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{- end -}}
|
||||
55
server/tpl/meta.info.html.tpl
Normal file
55
server/tpl/meta.info.html.tpl
Normal file
@@ -0,0 +1,55 @@
|
||||
{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}}
|
||||
{{- define "meta.info" -}}
|
||||
{{- $page := . -}}
|
||||
{{- $linkico := "🔗" }}
|
||||
<h2 id="client">Client/Browser Information<a href="#client">{{ $linkico }}</a></h2>
|
||||
<p>
|
||||
<b>Your IP Address is <i><a href="https://ipinfo.io/{{ $page.Info.IP.String }}">{{ $page.Info.IP.String }}</a></i>.</b>
|
||||
<br/>
|
||||
<i>You are connecting with port <b>{{ $page.Info.Port }}</b> outbound.</i>
|
||||
</p>
|
||||
{{- if $page.Raw }}
|
||||
<h3 id="client_raw">Raw Block ({{ $page.RawFmt }})<a href="#client_raw">{{ $linkico }}</a></h3>
|
||||
<p>
|
||||
{{- if $page.DoRawIndent }}
|
||||
<pre>{{ $page.Raw }}</pre>
|
||||
{{- else }}
|
||||
<code>{{ $page.Raw }}</code>
|
||||
{{- end }}
|
||||
</p>
|
||||
{{- end }}
|
||||
<h3 id="client_ua">User Agent Information<a href="#client_ua">{{ $linkico }}</a></h3>
|
||||
<p>This is information that your browser sends to identify itself.</p>
|
||||
<p>
|
||||
{{- range $idx, $ua := $page.Info.Client }}
|
||||
User Agent ({{ $idx }}):
|
||||
<ul>
|
||||
{{- $flds := $ua.ToMap }}
|
||||
{{- range $fld, $str := $flds }}
|
||||
<li><b>{{ $fld }}:</b> {{ $str }}</li>
|
||||
{{- end }}
|
||||
</ul>
|
||||
{{- end }}
|
||||
</p>
|
||||
<h3 id="client_hdrs">Request Headers<a href="#client_hdrs">{{ $linkico }}</a></h3>
|
||||
<p>
|
||||
These are headers sent along with the request your browser sends for the page's content.
|
||||
Note that some headers may have multiple values.
|
||||
</p>
|
||||
<p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Header</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
{{- range $hdrNm, $hdrVals := $page.Info.Req.Header }}
|
||||
<tr>
|
||||
{{- range $val := $hdrVals }}
|
||||
<td>{{ $hdrNm }}</td>
|
||||
<td>{{ $val }}</td>
|
||||
{{- end }}
|
||||
</tr>
|
||||
{{- end }}
|
||||
</table>
|
||||
</p>
|
||||
{{- end }}
|
||||
53
server/tpl/meta.top.html.tpl
Normal file
53
server/tpl/meta.top.html.tpl
Normal file
@@ -0,0 +1,53 @@
|
||||
{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}}
|
||||
{{- define "meta.top" -}}
|
||||
{{- $page := . -}}
|
||||
{{- $linkico := "🔗" -}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{ getTitle $page.PageType }}</title>
|
||||
<!-- Bootstrap core CSS -->
|
||||
<!--
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||
-->
|
||||
<!-- Tachyons -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/tachyons@4.12.0/css/tachyons.min.css"/>
|
||||
<!--
|
||||
Custom styles for Bootstrap
|
||||
-->
|
||||
<!--
|
||||
<link href="https://getbootstrap.com/examples/jumbotron-narrow/jumbotron-narrow.css"
|
||||
rel="stylesheet">
|
||||
-->
|
||||
<!--
|
||||
<link href="https://getbootstrap.com/docs/4.0/examples/offcanvas/offcanvas.css" rel="stylesheet">
|
||||
-->
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header clearfix">
|
||||
<nav>
|
||||
<ul class="nav nav-pills pull-right">
|
||||
<li role="presentation"><a href="/">Home</a></li>
|
||||
<li role="presentation"><a href="/about">About</a></li>
|
||||
<li role="presentation"><a href="/usage">Usage</a></li>
|
||||
<ul role="presentation">
|
||||
<li><a href="/?mime=application/json&indent">JSON</a></li>
|
||||
<li><a href="/?mime=application/xml&indent">XML</a></li>
|
||||
<li><a href="/?mime=application/yaml">YAML</a></li>
|
||||
<li><a href="/?mime=text/html">HTML (This Page)</a></li>
|
||||
</ul>
|
||||
<!--
|
||||
the following opens in a new tab/window/whatever.
|
||||
the line after opens in the same tab/window/etc.
|
||||
-->
|
||||
<!--
|
||||
<li role="presentation"><a href="https://r00t2.io/" target="_blank">r00t^2</a></li>
|
||||
-->
|
||||
<li role="presentation"><a href="https://r00t2.io/">r00t^2</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{{- end }}
|
||||
95
server/tpl/usage.html.tpl
Normal file
95
server/tpl/usage.html.tpl
Normal file
@@ -0,0 +1,95 @@
|
||||
{{- /*gotype: r00t2.io/clientinfo/server.Page*/ -}}
|
||||
{{- define "usage" }}
|
||||
{{- $page := . -}}
|
||||
{{- $linkico := "🔗" }}
|
||||
{{- template "meta.top" $page }}
|
||||
<div class="jumbotron">
|
||||
<h1>Usage</h1>
|
||||
</div>
|
||||
<h2 id="usage_params">Parameters<a href="#usage_params">{{ $linkico }}</a></h2>
|
||||
<p>
|
||||
You can control how the <a href="/">main page</a> displays/renders.
|
||||
By default, it will try to "guess" what you want; e.g. if you access it in Chrome, it will return HTML but if you fetch via Curl, you'll get raw JSON
|
||||
(or your specified data format; see below). If the classification of client can't be determined and an <code>Accept</code> wasn't specified,
|
||||
a fallback to text-mode (by default <code>application/json</code>) will be returned.
|
||||
<br/>
|
||||
|
||||
You can force a specific raw output by specifying the <a href="https://www.iana.org/assignments/media-types/media-types.xhtml">MIME type</a> via
|
||||
<a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.1">the <code>Accept</code> header (RFC 9110 § 12.5.1)</a>, which may be one of:
|
||||
<ul>
|
||||
<li><code>application/json</code> for <a href="https://www.rfc-editor.org/rfc/rfc8259.html">JSON</a></li>
|
||||
<li><code>application/xml</code> for <a href="https://www.rfc-editor.org/rfc/rfc7303.html">XML</a></li>
|
||||
<li><code>application/yaml</code> for <a href="https://www.rfc-editor.org/rfc/rfc9512.html">YAML</a></li>
|
||||
<li><code>text/html</code> for <a href="https://www.rfc-editor.org/rfc/rfc2854.html">HTML</a></li>
|
||||
</ul>
|
||||
For example: <code>Accept: application/json</code> will return JSON.
|
||||
<br/>
|
||||
|
||||
If unspecified and it is a text-mode client (e.g. Curl), the default is <code>application/json</code>.
|
||||
<code>text/html</code> may be used to force an HTML response from a text-only client,
|
||||
just as one of the <code>application/*</code> MIME types above may be used to force that "raw" text MIME type for a "graphical" browser client.
|
||||
The specification as defined by <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.1">RFC 9110 § 12.5.1</a> is completely
|
||||
valid to pass and will be parsed without error (provided the header value is RFC-compliant and IANA-compliant),
|
||||
though note that <code>application/xml</code> and <code>text/html</code>'s <code>charset</code> parameter will be entirely ignored;
|
||||
the returned XML/HTML is <b>always</b> Unicode (with UTF-8 encoding).
|
||||
<br/>
|
||||
|
||||
If no selectable MIME type is provided but an <code>Accept</code> was given, an error will be returned; specifically, a
|
||||
<a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-15.5.7"><code>406</code> status code (RFC 9110 § 15.5.7)</a>.
|
||||
In this case, supported MIME types will be returned in the response's <code>Accept</code> header.
|
||||
<br/>
|
||||
|
||||
Note that <a href="https://lynx.invisible-island.net/">Lynx</a> and <a href="http://elinks.or.cz/">Elinks</a> are considered "graphical"
|
||||
browsers by this program as they are HTML-centric.
|
||||
</p>
|
||||
<p id="usage_params_mod">
|
||||
The following parameters control/modify behavior.<a href="#usage_params_mod">{{ $linkico }}</a>
|
||||
<ul>
|
||||
<li>
|
||||
<b>mime:</b> Specify an explicit MIME type via URL instead of the <code>Accept</code> header as specified above.
|
||||
<ul>
|
||||
<li>This should only be used by clients in which it is impossible or particularly cumbersome to modify/specify headers.
|
||||
<code>Accept</code> is more performant.</li>
|
||||
<li>Only the first supported instance of this parameter will be used.</li>
|
||||
<li>Any of the defined MIME types above may be specified (e.g. <code>?mime=application/json</code>).</li>
|
||||
<li>If both this URL query parameter and the <code>Accept</code> header is specified, the URL query takes preference.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<b>include:</b> Include a <code><code></code> (or <code><pre></code>, depending on if indentation is needed/requested) block in the HTML for the specified MIME type as well.</li>
|
||||
<ul>
|
||||
<li>Only the first supported instance of this parameter will be used.</li>
|
||||
<li>
|
||||
The value <b>must</b> conform to the same rules/specifications as the <code>mime</code> parameter/<code>Accept</code> header.
|
||||
<ul>
|
||||
<li><code>include</code> may <b>not</b> be <code>text/html</code>; it will be ignored if this is set. Just learn to <code>ctrl+u</code>.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Only used if the evaluated return is HTML, ignored otherwise.</li>
|
||||
<li>Indentation can be specified via the <b>indent</b> parameter below (since indentation is otherwise meaningless to HTML returns).</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<b>indent:</b> Enable/specify indentation for JSON and XML output; ignored for others.
|
||||
<ul>
|
||||
<li>The default is to not indent. (Commonly referred to as "condensed" or "compressed" JSON/XML.)</li>
|
||||
<li>Only the first specified instance of this parameter will be used.</li>
|
||||
<li>If specified with a string value, use that string as each indent.
|
||||
<ul>
|
||||
<li>Be mindful of URL query parameter encoding,
|
||||
per <a href="https://www.rfc-editor.org/rfc/rfc3986.html#section-3.4">RFC 3986 § 3.4</a>
|
||||
and <a href="https://www.rfc-editor.org/rfc/rfc8820.html#section-2.4">RFC 8820 § 2.4</a></li>
|
||||
<li>For quick reference and as an example, to indent with a <a href="https://asciiref.dev/#c9">tab</a>
|
||||
(<code>\t</code>, <code>0x09</code>) for each level, use <code>?indent=%09</code></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>If indentation is specified without any value (<code>?indent</code>), the default is two
|
||||
<a href="https://asciiref.dev/#c32">spaces</a> (<code>0x20</code>); this would be represented
|
||||
as <code>?indent=%20%20</code></li>
|
||||
<li><code>?indent=</code> (no value specified) is equal to <code>?indent</code>.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</p>
|
||||
{{- template "meta.bottom" $page }}
|
||||
{{- end }}
|
||||
107
server/types.go
Normal file
107
server/types.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
`encoding/xml`
|
||||
`net`
|
||||
`net/http`
|
||||
`net/url`
|
||||
`os`
|
||||
|
||||
`github.com/mileusna/useragent`
|
||||
`r00t2.io/clientinfo/args`
|
||||
`r00t2.io/goutils/logging`
|
||||
)
|
||||
|
||||
type outerRenderer func(page *Page, resp http.ResponseWriter) (err error)
|
||||
type XmlHeaders map[string][]string
|
||||
|
||||
// R00tInfo is the structure of data returned to the client.
|
||||
type R00tInfo struct {
|
||||
// XMLName is the element name/namespace of this object ("info").
|
||||
XMLName xml.Name `json:"-" xml:"info" yaml:"-"`
|
||||
// Client is the UA/Client info, if any passed by the client.
|
||||
Client []*R00tClient `json:"ua,omitempty" xml:"ua,omitempty" yaml:"Client/User Agent,omitempty"`
|
||||
// IP is the client IP address.
|
||||
IP net.IP `json:"ip" xml:"ip,attr" yaml:"Client IP Address"`
|
||||
// Port is the client's port number.
|
||||
Port uint16 `json:"port" xml:"port,attr" yaml:"Client Port"`
|
||||
// Headers are the collection of the request headers sent by the client.
|
||||
Headers XmlHeaders `json:"headers" xml:"reqHeaders" yaml:"Request Headers"`
|
||||
// Req contains the original request. It is not rendered but may be used for templating.
|
||||
Req *http.Request `json:"-" xml:"-" yaml:"-"`
|
||||
}
|
||||
|
||||
// R00tClient is the UA/Client info, if any passed by the client.
|
||||
type R00tClient struct {
|
||||
// XMLName is the element name/namespace of this object ("ua").
|
||||
XMLName xml.Name `json:"-" xml:"ua" yaml:"-" uaField:"-" renderName:"-"`
|
||||
// String contains the entire UA string.
|
||||
String *string `json:"str,omitempty" xml:",chardata" yaml:"String"`
|
||||
// ClientVer is a parsed version structure of the client version (see ClientVerStr for the combined string).
|
||||
ClientVer *Ver `json:"ver,omitempty" xml:"version,omitempty" yaml:"Client Version,omitempty" uaField:"VersionNo" renderName:"-"`
|
||||
// OSVer is the parsed OS version info of the client (see OsVersionStr for the combined string).
|
||||
OSVer *Ver `json:"os_ver,omitempty" xml:"osVersion,omitempty" yaml:"Operating System Version,omitempty" uaField:"OSVersionNo" renderName:"-"`
|
||||
// URL, if any, is the URL of the client.
|
||||
URL *string `json:"url,omitempty" xml:"url,attr,omitempty" yaml:"URL,omitempty"`
|
||||
// Name is the client software name.
|
||||
Name *string `json:"name,omitempty" xml:"name,attr,omitempty" yaml:"Program/Name,omitempty"`
|
||||
// ClientVerStr contains the full version as a string (see also Clientversion).
|
||||
ClientVerStr *string `json:"ver_str,omitempty" xml:"verStr,attr,omitempty" yaml:"Client Version String,omitempty" uaField:"Version" renderName:"Client Version"`
|
||||
// OS is the operating system of the client.
|
||||
OS *string `json:"os,omitempty" xml:"os,attr,omitempty" yaml:"Operating System,omitempty"`
|
||||
// OsVerStr is the version of the operating system of the client.
|
||||
OsVerStr *string `json:"os_ver_str,omitempty" xml:"osVerStr,attr,omitempty" yaml:"Operating System Version String,omitempty" uaField:"OSVersion" renderName:"Operating System Version"`
|
||||
// Dev is the device type.
|
||||
Dev *string `json:"dev,omitempty" xml:"dev,attr,omitempty" yaml:"Device,omitempty" uaField:"Device"`
|
||||
// IsMobile is true if this is a mobile device.
|
||||
IsMobile bool `json:"mobile" xml:"mobile,attr" yaml:"Is Mobile" uaField:"Mobile"`
|
||||
// sTablet is true if this is a tablet.
|
||||
IsTablet bool `json:"tablet" xml:"tablet,attr" yaml:"Is Tablet" uaField:"Tablet"`
|
||||
// IsDesktop is true if this is a desktop/laptop.
|
||||
IsDesktop bool `json:"desktop" xml:"desktop,attr" yaml:"Is Desktop" uaField:"Desktop"`
|
||||
// IsBot is true if this is a bot.
|
||||
IsBot bool `json:"bot" xml:"bot,attr" yaml:"Is Bot" uaField:"Bot"`
|
||||
ua *useragent.UserAgent
|
||||
}
|
||||
|
||||
type Ver struct {
|
||||
// XMLName xml.Name `json:"-" xml:"version" yaml:"-"`
|
||||
Major int `json:"major" xml:"maj,attr" yaml:"Major"`
|
||||
Minor int `json:"minor" xml:"min,attr" yaml:"Minor"`
|
||||
Patch int `json:"patch" xml:"patch,attr" yaml:"Patch"`
|
||||
}
|
||||
|
||||
// Page is only used for HTML rendering.
|
||||
type Page struct {
|
||||
Info *R00tInfo
|
||||
// e.g. "index.html.tpl"; populated by handler
|
||||
PageType string
|
||||
// Nil unless `?include=` specified, otherwise a block of text to be wrapped in <code>...</code>.
|
||||
Raw *string
|
||||
// RawFmt is the MIME type for Raw, if `?include=` enabled/specified.
|
||||
RawFmt *string
|
||||
// Indent specifies the indentation string.
|
||||
Indent string
|
||||
// DoIndent indicates if indenting was enabled.
|
||||
DoIndent bool
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
log logging.Logger
|
||||
args *args.Args
|
||||
listenUri *url.URL
|
||||
isHttp bool
|
||||
mux *http.ServeMux
|
||||
sock net.Listener
|
||||
doneChan chan bool
|
||||
stopChan chan os.Signal
|
||||
reloadChan chan os.Signal
|
||||
isStopping bool
|
||||
}
|
||||
|
||||
// https://www.iana.org/assignments/media-types/media-types.xhtml
|
||||
type parsedMIME struct {
|
||||
MIME string
|
||||
Weight float32 // Technically a param (q; "qualifier"?), but copied and converted here for easier sorting.
|
||||
Params map[string]string
|
||||
}
|
||||
Reference in New Issue
Block a user