Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add fileviewer for browsing files via web server #115

Merged
merged 1 commit into from
Apr 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*~

# Go binaries
web/fileviewer/fileviewer
web/proxy/proxy
web/server/server

Expand Down
7 changes: 7 additions & 0 deletions WORKSPACE
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ go_repository(
version = "v2.4.0",
)

go_repository(
name = "com_github_gomarkdown_markdown",
importpath = "github.com/gomarkdown/markdown",
sum = "h1:7dT6mSWxX6R/7sB6FDSade73Q6BVL834Y1wJR/db+5o=",
version = "v0.0.0-20230714230225-84ecad09a30a",
)

go_rules_dependencies()

go_register_toolchains(version = "host")
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ go 1.12

require (
github.com/ghodss/yaml v1.0.0
github.com/gomarkdown/markdown v0.0.0-20230714230225-84ecad09a30a
gopkg.in/yaml.v2 v2.4.0 // indirect
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gomarkdown/markdown v0.0.0-20230714230225-84ecad09a30a h1:7dT6mSWxX6R/7sB6FDSade73Q6BVL834Y1wJR/db+5o=
github.com/gomarkdown/markdown v0.0.0-20230714230225-84ecad09a30a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
Expand Down
29 changes: 29 additions & 0 deletions web/fileviewer/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Copyright 2023 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")

go_library(
name = "fileviewer_lib",
srcs = ["fileviewer.go"],
importpath = "github.com/mbrukman/notebook/web/fileviewer",
visibility = ["//visibility:private"],
deps = ["@com_github_gomarkdown_markdown//:go_default_library"],
)

go_binary(
name = "fileviewer",
embed = [":fileviewer_lib"],
visibility = ["//visibility:public"],
)
64 changes: 64 additions & 0 deletions web/fileviewer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# File viewer

This is a simple web server which renders text files (including HTML, JS, CSS)
and Markdown files (dynamically rendered to HTML). Some use cases include
quickly browsing local directories via a web browser, including Markdown files
with relative links.

This is an early prototype, but you're welcome to play around with it and
provide feedback, potential new use cases, etc.

## Building

Build in the current directory:

```sh
$ go build .
```

Or you can build it from the top of the tree in a local checkout:

```sh
$ go build ./web/filevewer
```

Or you can build via Bazel:

```sh
$ bazel build //web/fileviewer
```

Or you can build it without having this repo locally:

```sh
$ go install github.com/mbrukman/notebook/web/fileviewer@latest
```

# Running

Run locally with a custom web root (only accessible from `localhost` by
default):

```sh
$ ./fileviewer -web-root ~/notebook
```

Expose it to everyone who can access this computer via the network:

```sh
$ ./fileviewer -web-root ~/notebook -host 0.0.0.0
```

Get a list of available flags:

```sh
$ ./fileviewer -help
```

Running via Bazel (this path is printed when you run the `bazel build ...`
command above):

```sh
$ bazel-bin/web/fileviewer/fileviewer_/fileviewer [...flags]
```

166 changes: 166 additions & 0 deletions web/fileviewer/fileviewer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package main

import (
"flag"
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"path"
"strings"

"github.com/gomarkdown/markdown"
)

var (
cwd, _ = os.Getwd()
webRoot = flag.String("web-root", cwd, "Root of the web file tree.")
host = flag.String("host", "127.0.0.1", "By default, the server is only accessible via localhost. "+
"Set to 0.0.0.0 or empty string to open to all.")
port = flag.String("port", getEnvWithDefault("PORT", "8080"), "Port to listen on; $PORT env var overrides default value.")
)

func getEnvWithDefault(varName, defaultValue string) string {
if value := os.Getenv(varName); value != "" {
return value
}
return defaultValue
}

func stringHasOneOfSuffixes(str string, suffixes []string) bool {
for _, suffix := range suffixes {
if strings.HasSuffix(str, suffix) {
return true
}
}
return false
}

type DocHandler struct {
webRoot string
}

func serveFile(rw http.ResponseWriter, mimeType string, fileContents []byte) {
rw.WriteHeader(http.StatusOK)
rw.Header().Set("Content-Type", fmt.Sprintf("%s; charset=UTF-8", mimeType))
rw.Write([]byte(fileContents))
}

func (handler *DocHandler) DispatchHandler(rw http.ResponseWriter, req *http.Request) {
log.Printf("Request: %s", req.URL.Path)
var urlPath string = req.URL.Path
// Remove any occurrences of `..` in the path to avoid escaping the web root.
urlPath = strings.ReplaceAll(urlPath, "..", "")
// Replace all multi-sequences of `/` with a single slash.
urlPath = strings.ReplaceAll(urlPath, "//", "/")
var fsPath string = path.Join(handler.webRoot, "/./", urlPath)

log.Printf("Local path: %s", fsPath)

fileInfo, err := os.Stat(fsPath)
if err != nil && os.IsNotExist(err) {
// File does not exist.
rw.WriteHeader(http.StatusNotFound)
rw.Header().Set("Content-Type", "text/html; charset=UTF-8")
rw.Write([]byte("<!DOCTYPE html>\n"))
rw.Write([]byte("<html>"))
rw.Write([]byte("<body>"))
rw.Write([]byte(fmt.Sprintf("<h1>Error 404: <code>%s</code> not found", urlPath)))
rw.Write([]byte("</body>"))
rw.Write([]byte("</html>"))

log.Printf("Path not found: %s", fsPath)
return
}

if fileInfo.IsDir() {
files, err := ioutil.ReadDir(fsPath)
if err != nil {
log.Printf("Error listing directory: %s", fsPath)
return
}

rw.WriteHeader(http.StatusOK)
rw.Header().Set("Content-Type", "text/html; charset=UTF-8")
rw.Write([]byte("<!DOCTYPE html>\n"))
rw.Write([]byte("<html>"))
rw.Write([]byte("<body>"))
rw.Write([]byte(fmt.Sprintf("<h1>Directory listing: %s</h1>", urlPath)))
rw.Write([]byte("<ul>"))
for _, file := range files {
// Skip internal files, e.g., `.git` directory, `.gitignore`, other dotfiles, etc.
if strings.HasPrefix(file.Name(), ".") {
continue
}
var listItem string
if urlPath == "/" {
listItem = fmt.Sprintf("<li><a href='%s'>%s</li>\n", file.Name(), file.Name())
} else {
listItem = fmt.Sprintf("<li><a href='%s/%s'>%s</li>\n", urlPath, file.Name(), file.Name())
}
rw.Write([]byte(listItem))
}
rw.Write([]byte("</ul>"))
rw.Write([]byte("</body>"))
rw.Write([]byte("</html>"))
return
}

fileContents, err := ioutil.ReadFile(fsPath)
if err != nil {
log.Printf("Error reading file (%s): %s", fsPath, err)
return
}

if strings.HasSuffix(fsPath, ".html") {
serveFile(rw, "text/html", fileContents)
} else if strings.HasSuffix(fsPath, ".js") {
serveFile(rw, "text/javascript", fileContents)
} else if strings.HasSuffix(fsPath, ".ts") {
serveFile(rw, "text/typescript", fileContents)
} else if strings.HasSuffix(fsPath, ".css") {
serveFile(rw, "text/css", fileContents)
} else if strings.HasSuffix(fsPath, ".md") {
rw.WriteHeader(http.StatusOK)
rw.Header().Set("Content-Type", "text/html; charset=UTF-8")
rw.Write([]byte(`<!doctype html>
<html>
<head>
<style>
code {
background-color: #efefef;
margin: 3px;
padding: 3px;
}
</style>
</head>
<body>`))
rw.Write(markdown.ToHTML(fileContents, nil, nil))
rw.Write([]byte(`</body>
</html>`))
} else if stringHasOneOfSuffixes(fsPath, []string{".txt", ".text", ".json", ".sh"}) {
rw.Header().Set("Content-Type", "text/plain; charset=UTF-8")
rw.Write(fileContents)
} else {
rw.Header().Set("Content-Type", "text/plain; charset=UTF-8")
rw.Write([]byte("Unrecognized file content type or suffix."))
log.Printf("Unrecognized file content type or suffix: %s", fsPath)
}
}

func NewDocHandler(webRoot string) *DocHandler {
return &DocHandler{webRoot: webRoot}
}

func main() {
flag.Parse()

handler := NewDocHandler(*webRoot)
http.HandleFunc("/", handler.DispatchHandler)

hostPort := fmt.Sprintf("%s:%s", *host, *port)
log.Printf("Listening on http://%s", hostPort)
log.Printf("Serving from %s", *webRoot)
log.Fatal(http.ListenAndServe(hostPort, nil))
}
Loading