Skip to content

Commit 7ac220d

Browse files
committed
Add fileviewer for browsing files via web server
1 parent c2f5aac commit 7ac220d

File tree

7 files changed

+270
-0
lines changed

7 files changed

+270
-0
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*~
44

55
# Go binaries
6+
web/fileviewer/fileviewer
67
web/proxy/proxy
78
web/server/server
89

WORKSPACE

+7
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ go_repository(
5656
version = "v2.4.0",
5757
)
5858

59+
go_repository(
60+
name = "com_github_gomarkdown_markdown",
61+
importpath = "github.com/gomarkdown/markdown",
62+
sum = "h1:7dT6mSWxX6R/7sB6FDSade73Q6BVL834Y1wJR/db+5o=",
63+
version = "v0.0.0-20230714230225-84ecad09a30a",
64+
)
65+
5966
go_rules_dependencies()
6067

6168
go_register_toolchains(version = "host")

go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ go 1.12
44

55
require (
66
github.com/ghodss/yaml v1.0.0
7+
github.com/gomarkdown/markdown v0.0.0-20230714230225-84ecad09a30a
78
gopkg.in/yaml.v2 v2.4.0 // indirect
89
)

go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
22
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
3+
github.com/gomarkdown/markdown v0.0.0-20230714230225-84ecad09a30a h1:7dT6mSWxX6R/7sB6FDSade73Q6BVL834Y1wJR/db+5o=
4+
github.com/gomarkdown/markdown v0.0.0-20230714230225-84ecad09a30a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
35
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
46
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
57
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=

web/fileviewer/BUILD.bazel

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Copyright 2023 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
16+
17+
go_library(
18+
name = "fileviewer_lib",
19+
srcs = ["fileviewer.go"],
20+
importpath = "github.com/mbrukman/notebook/web/fileviewer",
21+
visibility = ["//visibility:private"],
22+
deps = ["@com_github_gomarkdown_markdown//:go_default_library"],
23+
)
24+
25+
go_binary(
26+
name = "fileviewer",
27+
embed = [":fileviewer_lib"],
28+
visibility = ["//visibility:public"],
29+
)

web/fileviewer/README.md

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# File viewer
2+
3+
This is a simple web server which renders text files (including HTML, JS, CSS)
4+
and Markdown files (dynamically rendered to HTML). Some use cases include
5+
quickly browsing local directories via a web browser, including Markdown files
6+
with relative links.
7+
8+
This is an early prototype, but you're welcome to play around with it and
9+
provide feedback, potential new use cases, etc.
10+
11+
## Building
12+
13+
Build in the current directory:
14+
15+
```sh
16+
$ go build .
17+
```
18+
19+
Or you can build it from the top of the tree in a local checkout:
20+
21+
```sh
22+
$ go build ./web/filevewer
23+
```
24+
25+
Or you can build via Bazel:
26+
27+
```sh
28+
$ bazel build //web/fileviewer
29+
```
30+
31+
Or you can build it without having this repo locally:
32+
33+
```sh
34+
$ go install github.com/mbrukman/notebook/web/fileviewer@latest
35+
```
36+
37+
# Running
38+
39+
Run locally with a custom web root (only accessible from `localhost` by
40+
default):
41+
42+
```sh
43+
$ ./fileviewer -web-root ~/notebook
44+
```
45+
46+
Expose it to everyone who can access this computer via the network:
47+
48+
```sh
49+
$ ./fileviewer -web-root ~/notebook -host 0.0.0.0
50+
```
51+
52+
Get a list of available flags:
53+
54+
```sh
55+
$ ./fileviewer -help
56+
```
57+
58+
Running via Bazel (this path is printed when you run the `bazel build ...`
59+
command above):
60+
61+
```sh
62+
$ bazel-bin/web/fileviewer/fileviewer_/fileviewer [...flags]
63+
```
64+

web/fileviewer/fileviewer.go

+166
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package main
2+
3+
import (
4+
"flag"
5+
"fmt"
6+
"io/ioutil"
7+
"log"
8+
"net/http"
9+
"os"
10+
"path"
11+
"strings"
12+
13+
"github.com/gomarkdown/markdown"
14+
)
15+
16+
var (
17+
cwd, _ = os.Getwd()
18+
webRoot = flag.String("web-root", cwd, "Root of the web file tree.")
19+
host = flag.String("host", "127.0.0.1", "By default, the server is only accessible via localhost. "+
20+
"Set to 0.0.0.0 or empty string to open to all.")
21+
port = flag.String("port", getEnvWithDefault("PORT", "8080"), "Port to listen on; $PORT env var overrides default value.")
22+
)
23+
24+
func getEnvWithDefault(varName, defaultValue string) string {
25+
if value := os.Getenv(varName); value != "" {
26+
return value
27+
}
28+
return defaultValue
29+
}
30+
31+
func stringHasOneOfSuffixes(str string, suffixes []string) bool {
32+
for _, suffix := range suffixes {
33+
if strings.HasSuffix(str, suffix) {
34+
return true
35+
}
36+
}
37+
return false
38+
}
39+
40+
type DocHandler struct {
41+
webRoot string
42+
}
43+
44+
func serveFile(rw http.ResponseWriter, mimeType string, fileContents []byte) {
45+
rw.WriteHeader(http.StatusOK)
46+
rw.Header().Set("Content-Type", fmt.Sprintf("%s; charset=UTF-8", mimeType))
47+
rw.Write([]byte(fileContents))
48+
}
49+
50+
func (handler *DocHandler) DispatchHandler(rw http.ResponseWriter, req *http.Request) {
51+
log.Printf("Request: %s", req.URL.Path)
52+
var urlPath string = req.URL.Path
53+
// Remove any occurrences of `..` in the path to avoid escaping the web root.
54+
urlPath = strings.ReplaceAll(urlPath, "..", "")
55+
// Replace all multi-sequences of `/` with a single slash.
56+
urlPath = strings.ReplaceAll(urlPath, "//", "/")
57+
var fsPath string = path.Join(handler.webRoot, "/./", urlPath)
58+
59+
log.Printf("Local path: %s", fsPath)
60+
61+
fileInfo, err := os.Stat(fsPath)
62+
if err != nil && os.IsNotExist(err) {
63+
// File does not exist.
64+
rw.WriteHeader(http.StatusNotFound)
65+
rw.Header().Set("Content-Type", "text/html; charset=UTF-8")
66+
rw.Write([]byte("<!DOCTYPE html>\n"))
67+
rw.Write([]byte("<html>"))
68+
rw.Write([]byte("<body>"))
69+
rw.Write([]byte(fmt.Sprintf("<h1>Error 404: <code>%s</code> not found", urlPath)))
70+
rw.Write([]byte("</body>"))
71+
rw.Write([]byte("</html>"))
72+
73+
log.Printf("Path not found: %s", fsPath)
74+
return
75+
}
76+
77+
if fileInfo.IsDir() {
78+
files, err := ioutil.ReadDir(fsPath)
79+
if err != nil {
80+
log.Printf("Error listing directory: %s", fsPath)
81+
return
82+
}
83+
84+
rw.WriteHeader(http.StatusOK)
85+
rw.Header().Set("Content-Type", "text/html; charset=UTF-8")
86+
rw.Write([]byte("<!DOCTYPE html>\n"))
87+
rw.Write([]byte("<html>"))
88+
rw.Write([]byte("<body>"))
89+
rw.Write([]byte(fmt.Sprintf("<h1>Directory listing: %s</h1>", urlPath)))
90+
rw.Write([]byte("<ul>"))
91+
for _, file := range files {
92+
// Skip internal files, e.g., `.git` directory, `.gitignore`, other dotfiles, etc.
93+
if strings.HasPrefix(file.Name(), ".") {
94+
continue
95+
}
96+
var listItem string
97+
if urlPath == "/" {
98+
listItem = fmt.Sprintf("<li><a href='%s'>%s</li>\n", file.Name(), file.Name())
99+
} else {
100+
listItem = fmt.Sprintf("<li><a href='%s/%s'>%s</li>\n", urlPath, file.Name(), file.Name())
101+
}
102+
rw.Write([]byte(listItem))
103+
}
104+
rw.Write([]byte("</ul>"))
105+
rw.Write([]byte("</body>"))
106+
rw.Write([]byte("</html>"))
107+
return
108+
}
109+
110+
fileContents, err := ioutil.ReadFile(fsPath)
111+
if err != nil {
112+
log.Printf("Error reading file (%s): %s", fsPath, err)
113+
return
114+
}
115+
116+
if strings.HasSuffix(fsPath, ".html") {
117+
serveFile(rw, "text/html", fileContents)
118+
} else if strings.HasSuffix(fsPath, ".js") {
119+
serveFile(rw, "text/javascript", fileContents)
120+
} else if strings.HasSuffix(fsPath, ".ts") {
121+
serveFile(rw, "text/typescript", fileContents)
122+
} else if strings.HasSuffix(fsPath, ".css") {
123+
serveFile(rw, "text/css", fileContents)
124+
} else if strings.HasSuffix(fsPath, ".md") {
125+
rw.WriteHeader(http.StatusOK)
126+
rw.Header().Set("Content-Type", "text/html; charset=UTF-8")
127+
rw.Write([]byte(`<!doctype html>
128+
<html>
129+
<head>
130+
<style>
131+
code {
132+
background-color: #efefef;
133+
margin: 3px;
134+
padding: 3px;
135+
}
136+
</style>
137+
</head>
138+
<body>`))
139+
rw.Write(markdown.ToHTML(fileContents, nil, nil))
140+
rw.Write([]byte(`</body>
141+
</html>`))
142+
} else if stringHasOneOfSuffixes(fsPath, []string{".txt", ".text", ".json", ".sh"}) {
143+
rw.Header().Set("Content-Type", "text/plain; charset=UTF-8")
144+
rw.Write(fileContents)
145+
} else {
146+
rw.Header().Set("Content-Type", "text/plain; charset=UTF-8")
147+
rw.Write([]byte("Unrecognized file content type or suffix."))
148+
log.Printf("Unrecognized file content type or suffix: %s", fsPath)
149+
}
150+
}
151+
152+
func NewDocHandler(webRoot string) *DocHandler {
153+
return &DocHandler{webRoot: webRoot}
154+
}
155+
156+
func main() {
157+
flag.Parse()
158+
159+
handler := NewDocHandler(*webRoot)
160+
http.HandleFunc("/", handler.DispatchHandler)
161+
162+
hostPort := fmt.Sprintf("%s:%s", *host, *port)
163+
log.Printf("Listening on http://%s", hostPort)
164+
log.Printf("Serving from %s", *webRoot)
165+
log.Fatal(http.ListenAndServe(hostPort, nil))
166+
}

0 commit comments

Comments
 (0)