diff --git a/build.py b/build.py new file mode 100644 index 0000000..c273c14 --- /dev/null +++ b/build.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 + +import os, time, subprocess, getopt, sys + + +def runCmd(cmd): + p = subprocess.Popen(cmd, shell = True, stdout = subprocess.PIPE, stderr = subprocess.PIPE) + stdout = p.communicate()[0].decode('utf-8').strip() + return stdout + + +# Get last tag. +def lastTag(): + return runCmd('git describe --abbrev=0 --tags') + + +# Get current branch name. +def branch(): + return runCmd('git rev-parse --abbrev-ref HEAD') + + +# Get last git commit id. +def lastCommitId(): + return runCmd('git log --pretty=format:"%h" -1') + + +# Get package name in the current directory. +# E.g. github.com/m3ng9i/ran +def packageName(): + return runCmd("go list") + + +pkgName = "" + + +# Assemble build command. +def buildCmd(): + buildFlag = [] + + version = lastTag() + if version != "": + buildFlag.append("-X '{}/global._version_={}'".format(pkgName, version)) + + branchName = branch() + if branchName != "": + buildFlag.append("-X '{}/global._branch_={}'".format(pkgName, branchName)) + + commitId = lastCommitId() + if commitId != "": + buildFlag.append("-X '{}/global._commitId_={}'".format(pkgName, commitId)) + + # current time + buildFlag.append("-X '{}/global._buildTime_={}'".format(pkgName, time.strftime("%Y-%m-%d %H:%M %z"))) + + return 'go build -ldflags "{}"'.format(" ".join(buildFlag)) + + +validOSArch = { + "darwin": ["386", "amd64", "arm", "arm64"], + "dragonfly": ["amd64"], + "freebsd": ["386", "amd64", "arm"], + "linux": ["386", "amd64", "arm", "arm64", "ppc64", "ppc64le"], + "netbsd": ["386", "amd64", "arm"], + "openbsd": ["386", "amd64", "arm"], + "plan9": ["386", "amd64"], + "solaris": ["amd64"], + "windows": ["386", "amd64"], +} + + +# Check if GOOS and GOARCH is valid combinations. +# Learn more at https://golang.org/doc/install/source +def isValidOSArch(goos, goarch): + os = validOSArch.get(goos) + if os is None: + return False + + if goarch in os: + return True + + return False + + +# Build binary for current OS and architecture +def build(): + if subprocess.call(buildCmd(), shell = True) == 0: + print("Build finished.") + + +# Build binaries for specify OS and architecture +# pairs: valid GOOS/GOARCH pairs +# filePrefix: filename prefix used in output binaries +def buildPlatform(pairs, filePrefix): + cmd = buildCmd() + + for p in pairs: + filename = "{}_{}_{}".format(filePrefix, p[0], p[1]) + if p[0] == "windows": + filename += ".exe" + + c = "GOOS={} GOARCH={} {} -o {}".format(p[0], p[1], cmd, filename) + if subprocess.call(c, shell = True) == 0: + print("Build finished: {}".format(filename)) + else: + # build error + return + + print("All build finished.") + + +usage = """Go binary builder + +Usage: + + ./build.py [GOOS/GOARCH pairs...] + ./build.py [-h, --help] + +Examples: + + 1. Build binary for current OS and architecture: + + ./build.py + + 2. Build binary for windows/386: + + ./build.py windows/386 + + 3. Build binaries for windows/386, linux/386: + + ./build.py windows/386 linux/386 + +""" + + +errmsg = "Arguments are not valid GOOS/GOARCH pairs, use -h for help" + + +def main(): + + global pkgName + pkgName = packageName() + if pkgName == "": + sys.exit("Can not get package name, you must run this command under a go import path.") + + if len(sys.argv) <= 1: + build() + return + + validPairs = [] + + for arg in sys.argv[1:]: + arg = arg.lower() + + if arg in ["-h", "--help"]: + print(usage) + return + + pairs = arg.split("/") + if len(pairs) != 2: + sys.exit(errmsg) + + if isValidOSArch(pairs[0], pairs[1]) is False: + sys.exit(errmsg) + + validPairs.append(pairs) + + buildPlatform(validPairs, "ran") + + +if __name__ == "__main__": + main() diff --git a/global/config.go b/global/config.go new file mode 100755 index 0000000..fdb4aa2 --- /dev/null +++ b/global/config.go @@ -0,0 +1,300 @@ +package global + +import "os" +import "fmt" +import "flag" +import "strings" +import "path/filepath" +import "github.com/m3ng9i/ran/server" + + +// version information +var _version_ = "unknown" +var _branch_ = "unknown" +var _commitId_ = "unknown" +var _buildTime_ = "unknown" + +var versionInfo = fmt.Sprintf("Version: %s, Branch: %s, Build: %s, Build time: %s", + _version_, _branch_, _commitId_, _buildTime_) + + +// Setting about ran server +type Setting struct { + Port uint // HTTP port. Default is 8080. + ShowConf bool // If show config info in the log. + Debug bool // If turns on debug mode. Default is false. + server.Config +} + + +func (this *Setting) check() (errmsg []string) { + + if this.Port > 65535 || this.Port <= 0 { + errmsg = append(errmsg, "Available port range is 1-65535") + } + + for _, index := range this.IndexName { + name := filepath.Base(index) + if name != index { + errmsg = append(errmsg, "Filename of index can not include path separators") + break + } + } + + // If root is not correct, no need to check other variable in Setting structure + info, err := os.Stat(this.Root) + if err != nil { + if os.IsNotExist(err) { + errmsg = append(errmsg, fmt.Sprintf("Root '%s' is not exist", this.Root)) + } else { + errmsg = append(errmsg, fmt.Sprintf("Get stat of root directory error: %s", err.Error())) + } + goto END + } else { + if info.IsDir() == false { + errmsg = append(errmsg, fmt.Sprintf("Root is not a directory")) + goto END + } + + this.Root, err = filepath.Abs(this.Root) + if err != nil { + errmsg = append(errmsg, fmt.Sprintf("Can not convert root to absolute form: %s", err.Error())) + goto END + } + } + + if this.Path404 != nil { + *this.Path404 = filepath.Join(this.Root, *this.Path404) + + // check if 404 file is under root + root := this.Root + if !strings.HasSuffix(root, string(filepath.Separator)) { + root = root + string(filepath.Separator) + } + if !strings.HasPrefix(*this.Path404, root) { + errmsg = append(errmsg, "Path of 404 file can not be out of root directory") + goto END + } + + info, err = os.Stat(*this.Path404) + if err != nil { + if os.IsNotExist(err) { + errmsg = append(errmsg, fmt.Sprintf("404 file '%s' is not exist", *this.Path404)) + } else { + errmsg = append(errmsg, fmt.Sprintf("Get stat of 404 file error: %s", err.Error())) + } + } else { + if info.IsDir() { + errmsg = append(errmsg, fmt.Sprintf("404 file can not be a directory")) + } + } + } + + if this.Auth != nil { + if this.Auth.Username == "" || this.Auth.Password == "" { + errmsg = append(errmsg, "Username or password cannot be empty string") + } + + for _, p := range this.Auth.Paths { + if !strings.HasPrefix(p, "/") { + errmsg = append(errmsg, fmt.Sprintf(`Auth path must start with "/", got %s`, p)) + } + } + } + + END: return +} + + +func (this *Setting) String() string { + +s := `Root: %s +Port: %d +Path404: %s +IndexName: %s +ListDir: %t +Gzip: %t +Debug: %t +Digest auth: %t` + + path404 := "" + if this.Path404 != nil { + path404 = *this.Path404 + } + + s = fmt.Sprintf(s, + this.Root, + this.Port, + path404, + strings.Join(this.IndexName, ", "), + this.ListDir, + this.Gzip, + this.Debug, + !(this.Auth == nil)) + + return s +} + + +var Config *Setting + + +func defaultConfig() (c *Setting, err error) { + c = new(Setting) + + c.Root, err = os.Getwd() + if err != nil { + return + } + + c.Port = 8080 + c.Path404 = nil + c.IndexName = []string{"index.html", "index.htm"} + c.ListDir = false + c.Gzip = true + c.Debug = false + + return +} + + +func usage() { +s := `Ran: a simple static web server + +Usage: ran [Options...] + +Options: + + -r, -root= Root path of the site. Default is current working directory. + -p, -port= HTTP port. Default is 8080. + -404= Path of a custom 404 file, relative to Root. Example: /404.html. + -i, -index= File name of index, priority depends on the order of values. + Separate by colon. Example: -i "index.html:index.htm" + If not provide, default is index.html and index.htm. + -l, -listdir= When request a directory and no index file found, + if listdir is true, show file list of the directory, + if listdir is false, return 404 not found error. + Default is false. + -g, -gzip= If turn on gzip compression. Default is true. + -a, -auth= Turn on digest auth and set username and password (separate by colon). + After turn on digest auth, all the page require authentication. + +Other options: + + -showconf Show config info in the log. + -debug Turn on debug mode. + -v, -version Show version information. + -h, -help Show help message. + +Author: + + m3ng9i + + +` +fmt.Printf(s) +os.Exit(0) +} + + +func LoadConfig() { + + var err error + Config, err = defaultConfig() + if err != nil { + fmt.Fprintf(os.Stderr, err.Error()) + os.Exit(1) + } + + var configPath, root, path404, auth string + var port uint + var indexName server.Index + var version, help bool + + flag.StringVar(&configPath, "c", "", "Path of config file") + flag.StringVar(&configPath, "config", "", "Path of config file") + + if configPath != "" { + // TODO: load config file + } + + flag.UintVar( &port, "p", 0, "HTTP port") + flag.UintVar( &port, "port", 0, "HTTP port") + flag.StringVar(&root, "r", "", "Root path of the website") + flag.StringVar(&root, "root", "", "Root path of the website") + flag.StringVar(&path404, "404", "", "Path of a custom 404 file") + flag.StringVar(&auth, "a", "", "Username and password of digest auth, separate by colon") + flag.StringVar(&auth, "auth", "", "Username and password of digest auth, separate by colon") + flag.Var( &indexName, "i", "File name of index, separate by colon") + flag.Var( &indexName, "index", "File name of index, separate by colon") + flag.BoolVar( &Config.ListDir, "l", false, "Show file list of a directory") + flag.BoolVar( &Config.ListDir, "listdir", false, "Show file list of a directory") + flag.BoolVar( &Config.Gzip, "g", true, "Turn on/off gzip compression") + flag.BoolVar( &Config.Gzip, "gzip", true, "Turn on/off gzip compression") + flag.BoolVar( &Config.ShowConf, "showconf", false, "If show config info in the log") + flag.BoolVar( &Config.Debug, "debug", false, "Turn on debug mode") + flag.BoolVar( &version, "v", false, "Show version information") + flag.BoolVar( &version, "version", false, "Show version information") + flag.BoolVar( &help, "h", false, "-h") + flag.BoolVar( &help, "help", false, "-help") + + flag.Usage = usage + + flag.Parse() + + if help { + usage() + } + + if version { + fmt.Println(versionInfo) + os.Exit(0) + } + + if port > 0 { + Config.Port = port + } + + if root != "" { + Config.Root = root + } + + if path404 != "" { + Config.Path404 = &path404 + } + + if len(indexName) > 0 { + Config.IndexName = indexName + } + + if auth != "" { + if Config.Auth == nil { + Config.Auth = new(server.Auth) + } + authPair := strings.SplitN(auth, ":", 2) + if len(authPair) != 2 { + fmt.Fprintf(os.Stderr, "Config error: format of auth not correct") + os.Exit(1) + } + Config.Auth.Username = authPair[0] + Config.Auth.Password = authPair[1] + } + + // check Config + errmsg := Config.check() + if len(errmsg) == 1 { + fmt.Fprintf(os.Stderr, "Config error: %s\n", errmsg[0]) + os.Exit(1) + } else if len(errmsg) > 1 { + fmt.Fprintln(os.Stderr, "Config error:") + for i, msg := range errmsg { + fmt.Fprintf(os.Stderr, "%d. %s\n", i + 1, msg) + } + os.Exit(1) + } + + createLogger(Config.Debug) + + return +} + diff --git a/global/logger.go b/global/logger.go new file mode 100755 index 0000000..4ebbe10 --- /dev/null +++ b/global/logger.go @@ -0,0 +1,29 @@ +package global + +import "fmt" +import "os" +import "github.com/m3ng9i/go-utils/log" + + +var Logger *log.Logger + + +func createLogger(debug bool) { + var config log.Config + config.Layout = log.LY_DEFAULT + config.LayoutStyle = log.LS_DEFAULT + config.TimeFormat = log.TF_DEFAULT + if debug { + config.Level = log.DEBUG + } else { + config.Level = log.INFO + } + + var err error + Logger, err = log.New(os.Stdout, config) + if err != nil { + fmt.Fprintf(os.Stderr, err.Error()) + os.Exit(1) + } +} + diff --git a/ran.gif b/ran.gif new file mode 100644 index 0000000..07e92ab Binary files /dev/null and b/ran.gif differ diff --git a/ran.go b/ran.go new file mode 100755 index 0000000..16b9324 --- /dev/null +++ b/ran.go @@ -0,0 +1,61 @@ +package main + +import "syscall" +import "os/signal" +import "net/http" +import "os" +import "fmt" +import "strings" +import "sync" +import "github.com/m3ng9i/ran/global" +import "github.com/m3ng9i/ran/server" + + +func catchSignal() { + signal_channel := make(chan os.Signal, 1) + signal.Notify(signal_channel, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + go func() { + for value := range signal_channel { + global.Logger.Infof("System: Catch signal: %s, Ran is going to shutdown", value.String()) + global.Logger.Wait() + os.Exit(0) + } + }() +} + + +func main() { + + global.LoadConfig() + + defer func() { + global.Logger.Wait() + }() + + if global.Config.ShowConf { + for _, line := range strings.Split(global.Config.String(), "\n") { + line = strings.TrimSpace(line) + if line != "" { + global.Logger.Infof("Config: %s", line) + } + } + } + + catchSignal() + + var wg sync.WaitGroup + defer wg.Wait() + + global.Logger.Infof("System: Ran is running on port %d", global.Config.Port) + + server := server.NewRanServer(global.Config.Config, global.Logger) + + wg.Add(1) + go func() { + err := http.ListenAndServe(fmt.Sprintf(":%d", global.Config.Port), server.Serve()) + if err != nil { + global.Logger.Fatal(err) + } + wg.Done() + }() +} diff --git a/readme.md b/readme.md new file mode 100755 index 0000000..d0ca0df --- /dev/null +++ b/readme.md @@ -0,0 +1,168 @@ +Ran: a simple static web server written in Go +============================================= + +![Ran](ran.gif) + +Ran is a simple web server for serving static files. + +## Features + +- Directory listing +- Automatic gzip compression +- Digest authentication +- Access logging +- Custom 404 error file + +## What Ran for? + +- File sharing in LAN or home network +- Web application testing +- Personal web site hosting or demonstrating + +## Dependencies + +- [github.com/abbot/go-http-auth](https://github.com/abbot/go-http-auth) +- [github.com/oxtoacart/bpool](https://github.com/oxtoacart/bpool) +- [github.com/m3ng9i/go-utils/http](https://github.com/m3ng9i/go-utils) +- [github.com/m3ng9i/go-utils/log](https://github.com/m3ng9i/go-utils) +- [github.com/m3ng9i/go-utils/possible](https://github.com/m3ng9i/go-utils) +- [golang.org/x/net/context](https://github.com/golang/net) + +## Installation + +Use the command below to install the dependencies mentioned above, and build the binary into $GOPATH/bin. + +```bash +go get -u github.com/m3ng9i/ran +``` + +For convenience, you can move the ran binary to a directory in the PATH environment variable. + +You can also call `./build.py` command under the Ran source directory to write version information into the binary, so that `ran -v` will give a significant result. Run `./build.py -h` for help. + +## Download binary + +You can also download Ran binary without build it yourself. + +[Download Ran binary from the release page](https://github.com/m3ng9i/ran/releases). + +## Run Ran + +You can start a web server without any options by typing `ran` and press return in terminal window. This will use the following default configuration: + +Configuration | Default value +----------------------------|-------------------------------- +Root directory | the current working directory +Port | 8080 +Index file | index.html, index.htm +List files of directories | false +Gzip | true +Digest auth | false + +Open http://127.0.0.1:8080 in browser to see your website. + +You can use the options below to override the default configuration. + +``` +Options: + + -r, -root= Root path of the site. Default is current working directory. + -p, -port= HTTP port. Default is 8080. + -404= Path of a custom 404 file, relative to Root. Example: /404.html. + -i, -index= File name of index, priority depends on the order of values. + Separate by colon. Example: -i "index.html:index.htm" + If not provide, default is index.html and index.htm. + -l, -listdir= When request a directory and no index file found, + if listdir is true, show file list of the directory, + if listdir is false, return 404 not found error. + Default is false. + -g, -gzip= If turn on gzip compression. Default is true. + -a, -auth= Turn on digest auth and set username and password (separate by colon). + After turn on digest auth, all the page require authentication. +``` + +Example 1: Start a server in the current directory and set port to 8888: + +```bash +ran -p=8888 +``` + +Example 2: Set root to /tmp, list files of directories and set a custom 404 page: + +```bash +ran -p=/tmp -l=true -404=/404.html +``` + +Example 3: Close gzip compression, set access username and password: + +```bash +ran -g=false -a=user:pass +``` + +Example 4: Set custom index file: + +```bash +ran -i default.html:index.html +``` + +Other options: + +``` + -showconf Show config info in the log. + -debug Turn on debug mode. + -v, -version Show version information. + -h, -help Show help message. +``` + +## Tips and tricks + +### Execute permission + +Before running Ran binary or build.py, make sure they have execute permission. If don't, use `chmod u+x ` to set. + +### download parameter + +If you add `download` as a query string parameter in the url, the browser will download the file instead of displaying it in the browser window. Example: + +``` +http://127.0.0.1:8080/readme.html?download +``` + +### gzip parameter + +Gzip compression is enabled by default. Ran will gzip file automaticly according to the file extension. Example: a `.txt` file will be compressed and a `.jpg` file will not. + +If you add `gzip=true` in the url, Ran will force compress the file even if the file should not be compressed. Example: + +``` +http://127.0.0.1:8080/picture.jpg?gzip=true +``` + +If you add `gzip=false` in the url, Ran will not compress it even if it should be compressed. Example: + +``` +http://127.0.0.1:8080/large-file.txt?gzip=false +``` + +Read the source code of [CanBeCompressed()](https://github.com/m3ng9i/go-utils/blob/master/http/can_be_compressed.go) to learn more about automatic gzip compression. + +## ToDo + +The following functionalities will be added in the future: + +- Load config from file +- TLS encryption +- IP filter +- Custom log format +- etc + +## What's the meaning of Ran + +It's a Chinese PinYin and pronounce 燃, means flame burning. + +## Author + +mengqi + +If you like this project, please give me a star. + diff --git a/server/config.go b/server/config.go new file mode 100644 index 0000000..2c225e0 --- /dev/null +++ b/server/config.go @@ -0,0 +1,41 @@ +package server + +import "strings" + + +type Index []string + + +func (this *Index) String() string { + return strings.Join([]string(*this), ":") +} + + +func (this *Index) Set(value string) error { + *this = Index(strings.Split(value, ":")) + return nil +} + + +type Auth struct { + Username string + Password string + Paths []string // paths which use password to protect, relative to "/" + // if Paths is empty, all paths are protected. +} + + +type Config struct { + Root string // Root path of the website. Default is current working directory. + Path404 *string // Abspath of custom 404 file, under directory of Root. + // When a 404 not found error occurs, the file's content will be send to client. + // nil means do not use 404 file. + IndexName Index // File name of index, priority depends on the order of values. + // Default is []string{"index.html", "index.htm"}. + ListDir bool // If no index file provide, show file list of the directory. + // Default is false. + Gzip bool // If turn on gzip compression, default is true. + Auth *Auth // If not nil, turn on digest auth. +} + + diff --git a/server/context.go b/server/context.go new file mode 100644 index 0000000..a88fd1a --- /dev/null +++ b/server/context.go @@ -0,0 +1,130 @@ +package server + +import "net/http" +import "os" +import "path" +import "path/filepath" +import "strings" +import "fmt" +import "net/url" + + +// context contains information about request path +type context struct { + cleanPath string // clean path relative to root + url string // cleanPath + query string (used to do 307 redirect if r.url is not clean) + absFilePath string // absolute path pointing to a file or dir of the disk + exist bool + isDir bool + indexPath string // if path is a directory, detect index path + // indexPath is a path contains index name and relative to root + // indexPath == path.Join(cleanPath, indexName) +} + + +// String() is used for log output +func (c *context) String() string { + return fmt.Sprintf("cleanPath: %s, url: %s, absFilePath: %s, exist: %t, isDir: %t, indexPath: %s", + c.cleanPath, c.url, c.absFilePath, c.exist, c.isDir, c.indexPath) +} + + +// Make a new context +func newContext(config Config, r *http.Request) (c *context, err error) { + c = new(context) + + requestPath := r.URL.Path + + if !strings.HasPrefix(requestPath, "/") { + requestPath = "/" + requestPath + } + c.cleanPath = path.Clean(requestPath) + + c.absFilePath, err = filepath.Abs(filepath.Join(config.Root, c.cleanPath)) + if err != nil { + return + } + + info, e := os.Stat(c.absFilePath) + if e != nil { + if os.IsNotExist(e) { + c.exist = false + } else { + err = e + return + } + } else { + c.exist = true + c.isDir = info.IsDir() + } + + if c.isDir { + for _, name := range config.IndexName { + index := filepath.Join(c.absFilePath, name) + indexInfo, e := os.Stat(index) + if e != nil { + if os.IsNotExist(e) { + continue + } else { + err = e + return + } + } + + if indexInfo.IsDir() { + continue + } else { + c.isDir = false + c.indexPath = path.Join(c.cleanPath, name) + c.absFilePath, err = filepath.Abs(filepath.Join(config.Root, c.indexPath)) + if err != nil { + return + } + break + } + } + + if !config.ListDir && c.indexPath == "" { + c.exist = false + } + } + + c.url = c.cleanPath + if c.isDir && !strings.HasSuffix(c.url, "/") { + c.url += "/" + } + + // use net/url package to escape url + newurl := url.URL{Path: c.url, RawQuery:r.URL.RawQuery} + c.url = newurl.String() + + return +} + + +// Get parent from a url. Parameter url is start with "/". +func (this *context) parent() string { + u := this.url + + if u == "/" { + return "/" + } + + // remove query string (? and the string after it) + if i := strings.LastIndex(u, "?"); i > 0 { + u = u[:i] + } + + // remove last "/" + if strings.HasSuffix(u, "/") { + u = u[:len(u) - 1] + } + + i := strings.LastIndex(u, "/") + if i <= 0 { + return "/" + } + + return u[:i + 1] +} + diff --git a/server/dirlist.go b/server/dirlist.go new file mode 100644 index 0000000..66c1e77 --- /dev/null +++ b/server/dirlist.go @@ -0,0 +1,179 @@ +package server + +import "text/template" +import "fmt" +import "os" +import "time" +import "net/http" +import "net/url" +import "html" +import "path" +import "path/filepath" + + +type dirListFiles struct { + Name string + Url string + Size int64 + ModTime time.Time +} + + +type dirList struct { + Title string + Files []dirListFiles +} + + +const dirListTpl = ` + + + + +{{.Title}} + + + + + + +

{{.Title}}

+ + +{{range $files := .Files}} + + + + {{/* t2s example: {{ t2s .ModTime "2006-01-02 15:04"}} */}} + + +{{end}} +
NameSizeModification time
{{.Name}}{{.Size}}{{t2s .ModTime}}
+ + +` + + +var tplDirList *template.Template + + +func timeToString(t time.Time, format ...string) string { + f := "2006-01-02 15:04:05" + if len(format) > 0 && format[0] != "" { + f = format[0] + } + return t.Format(f) +} + + +func init() { + var err error + tplDirList = template.New("dirlist").Funcs(template.FuncMap{"t2s": timeToString}) + tplDirList, err = tplDirList.Parse(dirListTpl) + if err != nil { + fmt.Fprintf(os.Stderr, "Directory list template init error: %s", err.Error()) + os.Exit(1) + } +} + + +// List content of a directory. +// If error occurs, this function will return an error and won't write anything to ResponseWriter. +func (this *RanServer) listDir(w http.ResponseWriter, c *context) (size int64, err error) { + + if !c.exist { + size = Error(w, 404) + return + } + + if !c.isDir { + err = fmt.Errorf("Cannot list contents of a non-directory") + return + } + + f, err := os.Open(c.absFilePath) + if err != nil { + return + } + defer f.Close() + + info, err := f.Readdir(0) + if err != nil { + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + title := html.EscapeString(path.Base(c.cleanPath)) + + var files []dirListFiles + + for n, i := range info { + // TODO: skipped files according to ignore list in config + name := i.Name() + if i.IsDir() { + name += "/" + } + name = html.EscapeString(name) + url := url.URL{Path: name} + + // write parent dir + if n == 0 && c.cleanPath != "/" { + parent := c.parent() + + var info os.FileInfo + info, err = os.Stat(filepath.Join(this.config.Root, parent)) + if err != nil { + return + } + + files = append(files, dirListFiles{Name:"[..]", Url:parent, ModTime:info.ModTime()}) + } + + files = append(files, dirListFiles{Name:name, Url:url.String(), Size:i.Size(), ModTime:i.ModTime()}) + } + + data := dirList{ Title: title, Files: files} + + buf := bufferPool.Get() + defer bufferPool.Put(buf) + + tplDirList.Execute(buf, data) + size, _ = buf.WriteTo(w) + return +} + + diff --git a/server/error.go b/server/error.go new file mode 100644 index 0000000..7976ff8 --- /dev/null +++ b/server/error.go @@ -0,0 +1,60 @@ +package server + +import "net/http" +import "fmt" +import "io/ioutil" +import "path" +import hhelper "github.com/m3ng9i/go-utils/http" + + +// Write http error, accrording status code and msg, return number of bytes write to ResponseWriter. +// Parameter msg is a string contains html, could be ignored. +func ErrorEx(w http.ResponseWriter, code int, title, msg string) int64 { + status := http.StatusText(code) + if status == "" { + status = "Unknown" + } + status = fmt.Sprintf("%d %s", code, status) + + if title == "" { + title = status + } + + if msg == "" { + msg = "

" + status + "

" + } + + html := fmt.Sprintf(`%s%s`, + title, msg) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(code) + n, _ := fmt.Fprintln(w, html) + return int64(n) +} + + +// A convenient way to call ErrorEx() +func Error(w http.ResponseWriter, code int) int64 { + return ErrorEx(w, code, "", "") +} + + +// Write 404 file to client. +// abspath is path of 404 file. +func ErrorFile404(w http.ResponseWriter, abspath string) (int64, error) { + + b, err := ioutil.ReadFile(abspath) + if err != nil { + return 0, err + } + + contentType, _ := hhelper.FileContentType(path.Ext(abspath)) + if contentType == "" { + contentType = "text/html; charset=utf-8" + } + w.Header().Set("Content-Type", contentType) + w.WriteHeader(404) + n, _ := w.Write(b) + return int64(n), nil +} diff --git a/server/log_handler.go b/server/log_handler.go new file mode 100644 index 0000000..0d40378 --- /dev/null +++ b/server/log_handler.go @@ -0,0 +1,188 @@ +package server + +import "strconv" +import "errors" +import "fmt" +import "strings" +import "net/http" +import "time" +import hhelper "github.com/m3ng9i/go-utils/http" + + +type Header http.Header + + +func (this Header) String() string { + var s []string + for key, value := range this { + for _, val := range value { + s = append(s, fmt.Sprintf("%s: %s", key, val)) + } + } + return strings.Join(s, ", ") +} + + +var ErrInvalidLogLayout = errors.New("Invalid log layout") + +/* +LogLayout indicate what information will be present in access log. LogLayout is a string contains format specifiers. The format specifiers is a tag start with a percent sign and followd by a letter. The format specifiers will be replaced by corresponding values when the log is created. + +Below are format specifiers and there meanings: + +%% Percent sign (%) +%i Request id +%s Response status code +%h Host +%a Client ip address +%m Request method +%l Request url +%r Referer +%u User agent +%n Number of bytes transferred +%t Response time +%c Compression status (gzip / none) +*/ +type LogLayout string + + +var LogLayoutNormal LogLayout = `Access #%i: [Status: %s] [Host: %h] [IP: %a] [Method: %m] [URL: %l] [Referer: %r] [UA: %u] [Size: %n] [Time: %t] [Compression: %c]` + + +var LogLayoutShort LogLayout = `Access #%i: [%s] [%h] [%a] [%m] [%l] [%r] [%u] [%n] [%t] [%c]` + + +var LogLayoutMin LogLayout = `Access #%i: [%s] [%a] [%m] [%l] [%n]` + + +// Check if a log layout is legal. +func (this *LogLayout) IsLegal() bool { + + var in bool + + OUTER: + for _, c := range *this { + if in { + for _, ch := range []rune("%ishamlruntc") { + if c == ch { + in = false + continue OUTER + } + } + return false + } else { + if c == '%' { + in = true + } + } + } + + return true +} + + +func (this *RanServer) accessLog(sniffer *hhelper.ResponseSniffer, r *http.Request, responseTime int64) error { + + buf := bufferPool.Get() + defer bufferPool.Put(buf) + + var in bool + // TODO read layout from config + for _, c := range LogLayoutNormal { + if in { + switch c { + case '%': + buf.WriteString("%") + + // request id + case 'i': + buf.WriteString(sniffer.Header().Get("X-Request-Id")) + + // response status code + case 's': + buf.WriteString(strconv.Itoa(sniffer.Code)) + + // host + case 'h': + buf.WriteString(r.Host) + + // client ip address + case 'a': + buf.WriteString(hhelper.GetIP(r)) + + // request method + case 'm': + buf.WriteString(r.Method) + + // request url + case 'l': + buf.WriteString(r.URL.String()) + + // referer + case 'r': + buf.WriteString(r.Referer()) + + // user agent + case 'u': + buf.WriteString(r.Header.Get("User-Agent")) + + // number of bytes transferred + case 'n': + buf.WriteString(strconv.Itoa(sniffer.Size)) + + // response time + case 't': + rt := float64(responseTime) / 1000000 + buf.WriteString(fmt.Sprintf("%.3fms", rt)) + + // compression status (gzip / none) + case 'c': + contentEncoding := strings.ToLower(sniffer.Header().Get("Content-Encoding")) + if strings.Contains(contentEncoding, "gzip") { + buf.WriteString("gzip") + } else { + buf.WriteString("none") + } + + default: + return ErrInvalidLogLayout + } + + in = false + } else { + if c == '%' { + in = true + } else { + buf.WriteRune(c) + } + } + + } + + this.logger.Info(buf.String()) + return nil +} + + +func (this *RanServer) logHandler(fn http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + + sniffer := hhelper.NewSniffer(w, false) + + fn(sniffer, r) + + requestId := sniffer.Header().Get("X-Request-Id") + + this.logger.Debugf("#%s: Response headers: [%s]", requestId, Header(sniffer.Header()).String()) + + responseTime := time.Since(startTime).Nanoseconds() + + err := this.accessLog(sniffer, r, responseTime) + if err != nil { + this.logger.Errorf("#%s: accessLog(): %s", requestId, err) + } + } +} + + diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..c37a8a4 --- /dev/null +++ b/server/server.go @@ -0,0 +1,190 @@ +package server + +import "fmt" +import "errors" +import "net/http" +import "os" +import "time" +import "math/rand" +import "crypto/md5" +import "github.com/m3ng9i/go-utils/log" +import hhelper "github.com/m3ng9i/go-utils/http" + + +// serveFile() serve any request with content pointed by abspath. +func serveFile(w http.ResponseWriter, r *http.Request, abspath string) error { + f, err := os.Open(abspath) + if err != nil { + return err + } + + info, err := f.Stat() + if err != nil { + return err + } + + if info.IsDir() { + return errors.New("Cannot serve content of a directory") + } + + filename := info.Name() + + // TODO if client (use JavaScript) send a request head: 'Accept: "application/octet-stream"' then write the download header ? + // if the url contains a query like "?download", then download this file + _, ok := r.URL.Query()["download"] + if ok { + hhelper.WriteDownloadHeader(w, filename) + } + + // http.ServeContent() always return a status code of 200. + http.ServeContent(w, r, filename, info.ModTime(), f) + return nil +} + + +type RanServer struct { + config Config + logger *log.Logger +} + + +func NewRanServer(c Config, logger *log.Logger) *RanServer { + return &RanServer { + config: c, + logger: logger, + } +} + + +func (this *RanServer) serveHTTP(w http.ResponseWriter, r *http.Request) { + + requestId := string(getRequestId(r.URL.String())) + + w.Header().Set("X-Request-Id", requestId) + + this.logger.Debugf("#%s: r.URL: [%s]", requestId, r.URL.String()) + + context, err := newContext(this.config, r) + if err != nil { + Error(w, 500) + this.logger.Errorf("#%s: %s", requestId, err) + return + } + + this.logger.Debugf("#%s: Context: [%s]", requestId, context.String()) + + // redirect to a clean url + if r.URL.String() != context.url { + http.Redirect(w, r, context.url, http.StatusTemporaryRedirect) + return + } + + // display 404 error + if !context.exist { + if this.config.Path404 != nil { + _, err = ErrorFile404(w, *this.config.Path404) + if err != nil { + this.logger.Errorf("#%s: Load 404 file error: %s", requestId, err) + Error(w, 404) + } + } else { + Error(w, 404) + } + return + } + + // display index page + if context.indexPath != "" { + err := serveFile(w, r, context.absFilePath) + if err != nil { + Error(w, 500) + this.logger.Errorf("#%s: %s", requestId, err) + } + return + } + + // display directory list. + // if c.isDir is true, Config.ListDir must be true, + // so there is no need to check value of Config.ListDir. + if context.isDir { + // display file list of a directory + _, err = this.listDir(w, context) + if err != nil { + Error(w, 500) + this.logger.Errorf("#%s: %s", requestId, err) + } + return + } + + // serve the static file. + err = serveFile(w, r, context.absFilePath) + if err != nil { + Error(w, 500) + this.logger.Errorf("#%s: %s", requestId, err) + return + } + + return +} + + +// generate a random number in [300,2499], set n for more randomly number +func randTime(n ...int64) int { + + i := time.Now().Unix() + if len(n) > 0 { + i += n[0] + } + if i < 0 { + i = 1 + } + + rand.Seed(i) + return rand.Intn(2200) + 300 // [300,2499] +} + + +func (this *RanServer) Serve() http.HandlerFunc { + + // original ran server handler + handler := this.serveHTTP + + // gzip handler + if this.config.Gzip { + handler = hhelper.GzipHandler(handler, true, true) + } + + // digest handler + if this.config.Auth != nil { + + da := hhelper.DigestAuth { + Realm: "Identity authentication", + + Secret: func(user, realm string) string { + if user == this.config.Auth.Username { + md5sum := md5.Sum([]byte(fmt.Sprintf("%s:%s:%s", user, realm, this.config.Auth.Password))) + return fmt.Sprintf("%x", md5sum) + } + return "" + }, + + ClientCacheSize: 2000, + ClientCacheTolerance: 200, + } + + failFunc := func() { + // sleep 300~2499 milliseconds to prevent brute force attack + time.Sleep(time.Duration(randTime()) * time.Millisecond) + } + + handler = da.DigestAuthHandler(handler, nil, failFunc) + } + + // log handler + handler = this.logHandler(handler) + + return func(w http.ResponseWriter, r *http.Request) { + handler(w, r) + } +} + diff --git a/server/vars.go b/server/vars.go new file mode 100644 index 0000000..44fcd38 --- /dev/null +++ b/server/vars.go @@ -0,0 +1,13 @@ +package server + +import "github.com/oxtoacart/bpool" +import hhelper "github.com/m3ng9i/go-utils/http" + + +// TODO set number of buffer in config +// global buffer pool for ran server +var bufferPool = bpool.NewBufferPool(200) + +// a function to generate a 12 characters random request id. +var getRequestId = hhelper.RequestIdGenerator(12) +