package httpreverse
import (
"b612.me/apps/b612/httpreverse/rp"
"b612.me/apps/b612/utils"
"b612.me/starcrypto"
"b612.me/starlog"
"b612.me/starnet"
"b612.me/staros"
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"github.com/gabriel-vasile/mimetype"
"io"
"io/ioutil"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
var version = "2.1.0"
func (h *ReverseConfig) Run() error {
err := h.init()
if err != nil {
return err
}
h.httpmux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
c, ok := h.routes[request.Host]
if !ok {
if _, ok := h.routes[""]; ok {
c = h.routes[""]
} else {
if len(h.routes) > 0 {
for _, v := range h.routes {
c = v
break
}
}
}
}
rejectWith403 := func(writer http.ResponseWriter, request *http.Request) {
c.SetResponseHeader(writer)
writer.WriteHeader(403)
if len(c.warnpagedata) != 0 {
writer.Write(c.warnpagedata)
return
}
writer.Write([]byte(`
403 Forbidden
403 Forbidden
You Are Not Allowed to Access This Page
Please Contact Site Administrator For Help
B612 HTTP REVERSE SERVER
`))
}
starlog.Infof("<%s> Req Path:%s ListenAddr:%s UA:%s\n", c.Name, request.URL.Path, request.RemoteAddr, request.Header.Get("User-Agent"))
checkPath := request.URL.Path
if strings.HasSuffix(checkPath, "/") && checkPath != "/" {
checkPath = strings.TrimSuffix(checkPath, "/")
}
if c.blackpath.Leaf != nil {
bp := c.blackpath.NearestLeafWithVal(checkPath)
if bp != nil {
if ppr, ok := bp.Val.(bool); ok {
if ppr || (!ppr && bp.FullPath == checkPath) {
starlog.Errorf("<%s> Path:%s is in black path, reject request\n", c.Name, checkPath)
rejectWith403(writer, request)
return
}
}
}
}
if c.whitepath.Leaf != nil {
bp := c.whitepath.NearestLeafWithVal(checkPath)
if bp == nil {
starlog.Errorf("<%s> Path:%s is not in the write path, reject request\n", c.Name, checkPath)
rejectWith403(writer, request)
return
}
if ppr, ok := bp.Val.(bool); !ok {
starlog.Errorf("<%s> Path:%s is not in the write path, reject request\n", c.Name, checkPath)
rejectWith403(writer, request)
return
} else {
if !ppr && bp.FullPath != checkPath {
starlog.Errorf("<%s> Path:%s is not in the write path, reject request\n", c.Name, checkPath)
rejectWith403(writer, request)
return
}
}
}
if !c.BasicAuth(writer, request) {
c.SetResponseHeader(writer)
return
}
if !c.filter(writer, request) {
rejectWith403(writer, request)
return
}
leaf := c.router.NearestLeafWithVal(checkPath)
if request.URL.Path == "/" && c.rootLeaf != nil {
leaf = &Leaf{
Name: leaf.Name,
Val: c.rootLeaf,
Last: leaf.Last,
Next: leaf.Next,
FullPath: leaf.FullPath,
}
}
if leaf == nil {
starlog.Errorf("<%s> No Reverse Proxy Found For Path:%s\n", c.Name, request.URL.Path)
writer.WriteHeader(404)
writer.Write([]byte("404 Not Found"))
return
}
if rp, ok := leaf.Val.(*rp.ReverseProxy); ok {
rp.ServeHTTP(writer, request)
return
}
if page, ok := leaf.Val.(string); ok {
h.fileHandle(leaf.FullPath, page, writer, request)
return
}
writer.WriteHeader(500)
})
h.httpserver = http.Server{
Addr: fmt.Sprintf("%s:%d", h.Addr, h.Port),
Handler: &h.httpmux,
}
var realUsingsSSL bool
var realAllowHTTPWithHttps bool
for _, c := range h.Config {
if c.UsingSSL {
realUsingsSSL = true
}
if c.AutoGenerateCert {
h.autogenCert = true
}
if c.AllowHTTPWithHttps {
realAllowHTTPWithHttps = true
}
starlog.Infoln(c.Name + " Listening on " + h.Addr + ":" + strconv.Itoa(h.Port))
}
if !realUsingsSSL {
if err := h.httpserver.ListenAndServe(); err != http.ErrServerClosed {
return err
}
return nil
}
/*
if !realAllowHTTPWithHttps && !realAutoGenCert {
if h.httpserver.ListenAndServeTLS(h.Cert, h.Key); err != http.ErrServerClosed {
return err
}
return nil
}
*/
var lis net.Listener
lis, err = starnet.ListenTLSWithConfig("tcp", fmt.Sprintf("%s:%d", h.Addr, h.Port), &tls.Config{}, h.getCert, realAllowHTTPWithHttps)
if err != nil {
return err
}
defer lis.Close()
if err := h.httpserver.Serve(lis); err != http.ErrServerClosed {
return err
}
return nil
}
var toolCa *x509.Certificate
var toolCaKey any
func (h *ReverseConfig) CalcRange(r *http.Request) (int64, int64) {
var rangeStart, rangeEnd int64 = -1, -1
ranges := r.Header.Get("Range")
if ranges == "" {
return rangeStart, rangeEnd
}
if !strings.Contains(ranges, "bytes=") {
return rangeStart, rangeEnd
}
ranges = strings.TrimPrefix(ranges, "bytes=")
data := strings.Split(ranges, "-")
if len(data) == 0 {
return rangeStart, rangeEnd
}
rangeStart, _ = strconv.ParseInt(data[0], 10, 64)
if len(data) > 1 {
rangeEnd, _ = strconv.ParseInt(data[1], 10, 64)
}
if rangeEnd == 0 {
rangeEnd = -1
}
return rangeStart, rangeEnd
}
func (h *ReverseConfig) BuildHeader(w http.ResponseWriter, r *http.Request, fullpath string) error {
h.SetResponseHeader(w)
if r.Method == "OPTIONS" {
w.Header().Set("Allow", "OPTIONS,GET,HEAD")
w.Header().Set("Content-Length", "0")
}
w.Header().Set("Date", strings.ReplaceAll(time.Now().UTC().Format("Mon, 2 Jan 2006 15:04:05 MST"), "UTC", "GMT"))
if staros.IsFolder(fullpath) {
return nil
}
mime, _ := mimetype.DetectFile(fullpath)
if mime == nil {
w.Header().Set("Content-Type", "application/download")
w.Header().Set("Content-Disposition", "attachment;filename="+filepath.Base(fullpath))
w.Header().Set("Content-Transfer-Encoding", "binary")
} else {
w.Header().Set("Content-Type", mime.String())
}
if staros.Exists(fullpath) {
finfo, err := os.Stat(fullpath)
if err != nil {
w.WriteHeader(502)
w.Write([]byte("Failed to Read " + fullpath + ",reason is " + err.Error()))
return err
}
w.Header().Set("Accept-Ranges", "bytes")
w.Header().Set("ETag", starcrypto.Md5Str([]byte(finfo.ModTime().String())))
w.Header().Set("Last-Modified", strings.ReplaceAll(finfo.ModTime().UTC().Format("Mon, 2 Jan 2006 15:04:05 MST"), "UTC", "GMT"))
if r.Method != "OPTIONS" {
start, end := h.CalcRange(r)
if start != -1 {
if end == -1 {
w.Header().Set("Content-Length", strconv.FormatInt(finfo.Size()-start, 10))
w.Header().Set("Content-Range", `bytes `+strconv.FormatInt(start, 10)+"-"+strconv.FormatInt(finfo.Size()-1, 10)+"/"+strconv.FormatInt(finfo.Size(), 10))
//w.Header().Set("Content-Length", strconv.FormatInt(fpinfo.Size()-rangeStart, 10))
} else {
w.Header().Set("Content-Length", strconv.FormatInt(end-start+1, 10))
w.Header().Set("Content-Range", `bytes `+strconv.FormatInt(start, 10)+"-"+strconv.FormatInt(end, 10)+"/"+strconv.FormatInt(finfo.Size(), 10))
//w.Header().Set("Content-Length", strconv.FormatInt(1+rangeEnd-rangeStart, 10))
}
} else {
w.Header().Set("Content-Length", strconv.FormatInt(finfo.Size(), 10))
}
}
}
return nil
}
func (h *ReverseConfig) getFile(w http.ResponseWriter, r *http.Request, fullpath string) error {
if !staros.Exists(fullpath) {
w.WriteHeader(404)
return errors.New("File Not Found! 404 ERROR")
}
//starlog.Debugln(r.Header)
startRange, endRange := h.CalcRange(r)
fp, err := os.Open(fullpath)
if err != nil {
starlog.Errorf("Failed to open file %s,reason:%v\n", r.URL.Path, err)
w.WriteHeader(502)
w.Write([]byte(`B612 Http Server502 SERVER ERROR
`))
return err
}
defer fp.Close()
var transferData int
defer func() {
if transferData != 0 {
var tani string
tani = fmt.Sprintf("%v Byte", transferData)
if f64 := float64(transferData) / 1024; f64 > 1 {
tani = fmt.Sprintf("%v KiB", f64)
if f64 = float64(f64) / 1024; f64 > 1 {
tani = fmt.Sprintf("%v MiB", f64)
if f64 = float64(f64) / 1024; f64 > 1 {
tani = fmt.Sprintf("%v GiB", f64)
}
}
}
starlog.Infof("Tranfered File %s %d bytes (%s) to remote %v\n", r.URL.Path,
transferData, tani, r.RemoteAddr)
}
}()
if startRange == -1 {
w.WriteHeader(200)
for {
buf := make([]byte, 16384)
n, err := fp.Read(buf)
if n != 0 {
ns, err := w.Write(buf[:n])
transferData += ns
if err != nil {
starlog.Errorf("Transfer File %s to Remote Failed:%v\n", fullpath, err)
return err
}
}
if err != nil {
if err == io.EOF {
break
}
starlog.Errorf("Read File %s Failed:%v\n", fullpath, err)
return err
}
}
return nil
}
starlog.Debugf("206 transfer mode for %v %v start %v end %v\n", r.URL.Path, r.RemoteAddr, startRange, endRange)
w.WriteHeader(206)
fp.Seek(int64(startRange), 0)
count := startRange
for {
buf := make([]byte, 16384)
n, err := fp.Read(buf)
if err != nil {
if err == io.EOF {
break
}
starlog.Errorf("Read File %s Failed:%v\n", r.URL.Path, err)
return err
}
if endRange == -1 {
ns, err := w.Write(buf[:n])
transferData += ns
if err != nil {
starlog.Errorf("Transfer File %s to Remote Failed:%v\n", r.URL.Path, err)
return err
}
} else {
if count > endRange {
break
}
writeNum := n
if count+int64(n) > endRange {
writeNum = int(endRange - count + 1)
}
ns, err := w.Write(buf[0:writeNum])
if err != nil {
starlog.Errorln("Transfer Error:", err)
return err
}
transferData += ns
count += int64(n)
}
}
return nil
}
func (h *ReverseConfig) fileHandle(dirPath, diskpath string, writer http.ResponseWriter, request *http.Request) {
var checkUrl = request.URL.Path
if strings.HasSuffix(request.URL.Path, "/") && len(request.URL.Path) > 1 {
checkUrl = strings.TrimSuffix(request.URL.Path, "/")
}
if staros.IsFile(diskpath) {
if checkUrl != dirPath {
h.SetResponseHeader(writer)
writer.WriteHeader(404)
return
}
h.BuildHeader(writer, request, diskpath)
if request.Method == "OPTIONS" {
return
}
h.getFile(writer, request, diskpath)
return
}
reqPath := strings.TrimPrefix(checkUrl, dirPath)
realPath := filepath.Join(diskpath, reqPath)
if !staros.Exists(realPath) {
h.SetResponseHeader(writer)
writer.WriteHeader(404)
return
}
if staros.IsFile(realPath) {
h.BuildHeader(writer, request, realPath)
if request.Method == "OPTIONS" {
return
}
h.getFile(writer, request, realPath)
return
}
h.SetResponseHeader(writer)
writer.WriteHeader(403)
}
func (h *ReverseConfig) getCert(hostname string) *tls.Config {
if tlsCfg, ok := h.hostnameTlsCache[hostname]; ok {
return tlsCfg
}
c, ok := h.routes[hostname]
if !ok {
if h.autogenCert {
return h.autoGenCert(hostname)
}
if _, ok := h.routes[""]; ok {
c = h.routes[""]
} else {
if len(h.routes) > 0 {
for _, v := range h.routes {
c = v
break
}
}
}
}
if c == nil {
return &tls.Config{}
}
if c.AutoGenerateCert {
return h.autoGenCert(hostname)
}
cert, err := tls.LoadX509KeyPair(c.Cert, c.Key)
if err != nil {
starlog.Errorln("Load X509 Key Pair Error:", err)
return &tls.Config{}
}
h.Lock()
if h.hostnameTlsCache == nil {
h.hostnameTlsCache = make(map[string]*tls.Config)
}
if tlsCfg, ok := h.hostnameTlsCache[hostname]; ok {
h.Unlock()
return tlsCfg
}
h.hostnameTlsCache[hostname] = &tls.Config{
Certificates: []tls.Certificate{cert},
}
h.Unlock()
return h.hostnameTlsCache[hostname]
}
func (h *ReverseConfig) autoGenCert(hostname string) *tls.Config {
if toolCa == nil {
toolCa, toolCaKey = utils.ToolCert("")
}
cert, err := utils.GenerateTlsCert(utils.GenerateCertParams{
Country: "CN",
Organization: "B612 HTTP SERVER",
OrganizationUnit: "cert@b612.me",
CommonName: hostname,
Dns: []string{hostname},
KeyUsage: int(x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign),
ExtendedKeyUsage: []int{
int(x509.ExtKeyUsageServerAuth),
int(x509.ExtKeyUsageClientAuth),
},
IsCA: false,
StartDate: time.Now().Add(-24 * time.Hour),
EndDate: time.Now().AddDate(1, 0, 0),
Type: "RSA",
Bits: 2048,
CA: toolCa,
CAPriv: toolCaKey,
})
if err != nil {
return nil
}
h.Lock()
if h.hostnameTlsCache == nil {
h.hostnameTlsCache = make(map[string]*tls.Config)
}
if tlsCfg, ok := h.hostnameTlsCache[hostname]; ok {
h.Unlock()
return tlsCfg
}
h.hostnameTlsCache[hostname] = &tls.Config{Certificates: []tls.Certificate{cert}}
h.Unlock()
return h.hostnameTlsCache[hostname]
}
func (h *ReverseConfig) Close() error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return h.httpserver.Shutdown(ctx)
}
func (h *SingleReverseConfig) dialTLS(ctx context.Context, network, addr string) (net.Conn, error) {
var host string
if h.ProxyHost != "" && h.ProxyHost != "$user" {
host = h.ProxyHost
} else if h.ProxyHost != "" {
if val, ok := ctx.Value("realhost").(string); ok {
host = val
}
} else {
host, _, _ = net.SplitHostPort(addr)
}
conn, err := net.DialTimeout(network, addr, time.Second*20)
if err != nil {
return nil, err
}
if host == "" {
host, _, err = net.SplitHostPort(addr)
if err != nil {
return nil, err
}
}
cfg := &tls.Config{ServerName: host}
tlsConn := tls.Client(conn, cfg)
if err := tlsConn.Handshake(); err != nil {
conn.Close()
return nil, err
}
cs := tlsConn.ConnectionState()
cert := cs.PeerCertificates[0]
// Verify here
if !h.SkipSSLVerify {
err = cert.VerifyHostname(host)
if err != nil {
return nil, err
}
}
return tlsConn, nil
}
func (h *SingleReverseConfig) dial(ctx context.Context, network, addr string) (net.Conn, error) {
conn, err := net.DialTimeout(network, addr, time.Second*20)
if err != nil {
return nil, err
}
return conn, nil
}
func (h *ReverseConfig) init() error {
for _, v := range h.Config {
v.init()
}
return nil
}
func (h *SingleReverseConfig) init() error {
h.proxy = make(map[string]*rp.ReverseProxy)
for key, val := range h.ReverseURL {
switch tp := val.(type) {
case *url.URL:
if err := h.newReverseProxy(key, tp); err != nil {
return fmt.Errorf("new reverse proxy for %s failed: %w", key, err)
}
case string:
if h.httpPage == nil {
h.httpPage = make(map[string]string)
}
h.httpPage[key] = tp
if key == "=/" {
h.rootLeaf = tp
continue
}
h.router.AddLeaf(key, tp)
}
}
return nil
}
func (h *SingleReverseConfig) newReverseProxy(key string, val *url.URL) error {
h.proxy[key] = &rp.ReverseProxy{
Transport: &http.Transport{DialTLSContext: h.dialTLS, DialContext: h.dial},
}
h.proxy[key].ModifyResponse = h.ModifyResponse()
h.proxy[key].Director = func(req *http.Request) {
targetQuery := val.RawQuery
req.URL.Scheme = val.Scheme
if h.ProxyHost != "" {
if h.ProxyHost != "$user" {
req.Host = h.ProxyHost
} else {
ctx := context.WithValue(req.Context(), "realhost", req.Host)
*req = *req.WithContext(ctx)
}
} else {
req.Host = val.Host
}
req.URL.Host = val.Host
req.URL.Path, req.URL.RawPath = joinURLPath(val, req.URL, key)
if targetQuery == "" || req.URL.RawQuery == "" {
req.URL.RawQuery = targetQuery + req.URL.RawQuery
} else {
req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
}
h.ModifyRequest(req, val)
}
if key == "=/" {
h.rootLeaf = h.proxy[key]
return nil
}
h.router.AddLeaf(key, h.proxy[key])
return nil
}
func (h *ReverseConfig) SetResponseHeader(resp http.ResponseWriter) {
resp.Header().Set("X-Powered-By", "B612.ME")
resp.Header().Set("Server", "B612/"+version)
resp.Header().Set("X-Proxy", "B612 Reverse Proxy")
}
func (h *SingleReverseConfig) SetResponseHeader(resp http.ResponseWriter) {
resp.Header().Set("X-Powered-By", "B612.ME")
resp.Header().Set("Server", "B612/"+version)
resp.Header().Set("X-Proxy", "B612 Reverse Proxy")
}
func (h *SingleReverseConfig) ModifyResponse() func(*http.Response) error {
return func(resp *http.Response) error {
for _, v := range h.OutHeader {
resp.Header.Set(v[0], v[1])
}
resp.Header.Del("X-Powered-By")
resp.Header.Del("Server")
resp.Header.Del("X-Proxy")
resp.Header.Set("X-Powered-By", "B612.ME")
resp.Header.Set("Server", "B612/"+version)
resp.Header.Set("X-Proxy", "B612 Reverse Proxy")
if len(h.ReplaceList) != 0 && resp.ContentLength <= 20*1024*1024 && strings.Contains(resp.Header.Get("Content-Type"), "text") {
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
strBody := string(body)
replaceCount := -1
if h.ReplaceOnce {
replaceCount = 1
}
for _, v := range h.ReplaceList {
strBody = strings.Replace(strBody, v[0], v[1], replaceCount)
}
body = []byte(strBody)
resp.Body = ioutil.NopCloser(bytes.NewReader(body))
resp.ContentLength = int64(len(body))
resp.Header.Set("Content-Length", strconv.Itoa(len(body)))
}
return nil
}
}
func (h *SingleReverseConfig) isInCIDR(ip string) bool {
nip := net.ParseIP(strings.TrimSpace(ip))
if nip == nil {
return false
}
for _, c := range h.CIDR {
if c.Contains(nip) {
return true
}
}
return false
}
func (h *SingleReverseConfig) ModifyRequest(req *http.Request, remote *url.URL) {
switch h.IPFilterMode {
case 1:
req.Header.Set("X-Forwarded-For", strings.Split(req.RemoteAddr, ":")[0])
case 2:
xforward := strings.Split(strings.TrimSpace(req.Header.Get("X-Forwarded-For")), ",")
xforward = append(xforward, strings.Split(req.RemoteAddr, ":")[0])
req.Header.Set("X-Forwarded-For", strings.Join(xforward, ", "))
case 3:
var lastForwardIP string
var xforward []string
if h.FilterMustKey != "" && req.Header.Get(h.FilterMustKey) != "" {
lastForwardIP = req.Header.Get(h.FilterMustKey)
xforward = []string{lastForwardIP}
} else {
for _, ip := range append(strings.Split(strings.TrimSpace(req.Header.Get("X-Forwarded-For")), ","), strings.Split(req.RemoteAddr, ":")[0]) {
ip = strings.TrimSpace(ip)
if !h.isInCIDR(ip) {
xforward = append(xforward, ip)
lastForwardIP = ip
}
}
}
if lastForwardIP == "" {
lastForwardIP = strings.Split(req.RemoteAddr, ":")[0]
}
if h.FilterXForward {
req.Header.Set("X-Forwarded-For", strings.Join(xforward, ", "))
}
if h.FilterRemoteAddr {
req.Header.Set("X-Real-IP", lastForwardIP)
}
if h.FilterSetKey != "" {
req.Header.Set(h.FilterSetKey, lastForwardIP)
}
}
if len(h.ReplaceList) > 0 {
req.Header.Set("Accept-Encoding", "deflate")
}
for _, v := range h.Cookie {
req.AddCookie(&http.Cookie{
Name: v[1],
Value: v[2],
Path: v[0],
})
}
for _, v := range h.InHeader {
req.Header.Set(v[0], v[1])
}
}
func (h *SingleReverseConfig) GiveBasicAuth(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", ` Basic realm="Please Enter Passwd"`)
w.WriteHeader(401)
w.Write([]byte(`
401 Authorization Required
401 Authorization Required
B612 HTTP SERVER
`))
}
func (h *SingleReverseConfig) BasicAuth(w http.ResponseWriter, r *http.Request) bool {
if h.basicAuthPwd != "" {
if len(h.protectAuthPage) != 0 {
for _, v := range h.protectAuthPage {
if !(strings.Index(r.URL.Path, v) == 0 || strings.Contains(r.URL.RawQuery, v)) {
return true
} else {
break
}
}
}
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
if len(authHeader) == 0 {
h.GiveBasicAuth(w)
return false
} else {
userAuth := base64.StdEncoding.EncodeToString([]byte(h.basicAuthUser + ":" + h.basicAuthPwd))
authStr := strings.Split(authHeader, " ")
if strings.TrimSpace(authStr[1]) != userAuth || strings.ToLower(strings.TrimSpace(authStr[0])) != "basic" {
h.GiveBasicAuth(w)
return false
}
}
}
return true
}
func (h *SingleReverseConfig) filter(w http.ResponseWriter, r *http.Request) bool {
if len(h.blackip) == 0 && len(h.whiteip) == 0 {
return true
}
if len(h.whiteip) != 0 {
if _, ok := h.whiteip[strings.Split(r.RemoteAddr, ":")[0]]; ok {
return true
}
for k, v := range h.whiteip {
if match, _ := IPinRange2(k, v, strings.Split(r.RemoteAddr, ":")[0]); match {
return true
}
}
return false
}
if _, ok := h.blackip[strings.Split(r.RemoteAddr, ":")[0]]; ok {
return false
}
for k, v := range h.blackip {
if match, _ := IPinRange2(k, v, strings.Split(r.RemoteAddr, ":")[0]); match {
return false
}
}
return true
}
func joinURLPath(a, b *url.URL, hpath string) (path, rawpath string) {
if hpath != "/" {
b.Path = strings.TrimPrefix(b.Path, hpath)
b.RawPath = strings.TrimPrefix(b.RawPath, hpath)
}
if a.RawPath == "" && b.RawPath == "" {
return singleJoiningSlash(a.Path, b.Path), ""
}
// Same as singleJoiningSlash, but uses EscapedPath to determine
// whether a slash should be added
apath := a.EscapedPath()
bpath := b.EscapedPath()
aslash := strings.HasSuffix(apath, "/")
bslash := strings.HasPrefix(bpath, "/")
switch {
case aslash && bslash:
return a.Path + b.Path[1:], apath + bpath[1:]
case !aslash && !bslash:
return a.Path + "/" + b.Path, apath + "/" + bpath
}
return a.Path + b.Path, apath + bpath
}
func singleJoiningSlash(a, b string) string {
aslash := strings.HasSuffix(a, "/")
bslash := strings.HasPrefix(b, "/")
switch {
case aslash && bslash:
return a + b[1:]
case !aslash && !bslash:
return a + "/" + b
}
return a + b
}