aboutsummaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
authorFeuerfuchs <git@feuerfuchs.dev>2020-05-18 12:12:43 +0200
committerFeuerfuchs <git@feuerfuchs.dev>2020-05-18 12:12:43 +0200
commit4bf44b16562335b3d09b6df0150521bb5b5f776f (patch)
tree576723e6dc9f9db48d0892f7ec354a11b973aef4 /internal
parentAdded 2 more glyphs (diff)
downloadgopherproxy-4bf44b16562335b3d09b6df0150521bb5b5f776f.tar.gz
gopherproxy-4bf44b16562335b3d09b6df0150521bb5b5f776f.tar.bz2
gopherproxy-4bf44b16562335b3d09b6df0150521bb5b5f776f.zip
WIP: Refactoring
Diffstat (limited to 'internal')
-rw-r--r--internal/port/gemini.go205
-rw-r--r--internal/port/gopher.go217
-rw-r--r--internal/port/main.go301
-rw-r--r--internal/port/tpl/gemini.html0
-rw-r--r--internal/port/tpl/gopher.html0
-rw-r--r--internal/port/tpl/startpage.html120
6 files changed, 843 insertions, 0 deletions
diff --git a/internal/port/gemini.go b/internal/port/gemini.go
new file mode 100644
index 0000000..f9b0b97
--- /dev/null
+++ b/internal/port/gemini.go
@@ -0,0 +1,205 @@
1package port
2
3import (
4 "bytes"
5 "fmt"
6 "html/template"
7 "io"
8 "log"
9 "mime"
10 "net/http"
11 "net/url"
12 "regexp"
13 "strings"
14
15 "golang.org/x/net/html/charset"
16 "golang.org/x/text/transform"
17
18 "git.vulpes.one/Feuerfuchs/port/port/libgemini"
19
20 "github.com/temoto/robotstxt"
21)
22
23var (
24 TermEscapeSGRPattern = regexp.MustCompile("\\[\\d+(;\\d+)*m")
25)
26
27func resolveURI(uri string, baseURL *url.URL) (resolvedURI string) {
28 if strings.HasPrefix(uri, "//") {
29 resolvedURI = "/gemini/" + strings.TrimPrefix(uri, "//")
30 } else if strings.HasPrefix(uri, "gemini://") {
31 resolvedURI = "/gemini/" + strings.TrimPrefix(uri, "gemini://")
32 } else if strings.HasPrefix(uri, "gopher://") {
33 resolvedURI = "/gopher/" + strings.TrimPrefix(uri, "gopher://")
34 } else {
35 url, err := url.Parse(uri)
36 if err != nil {
37 return ""
38 }
39 adjustedURI := baseURL.ResolveReference(url)
40 path := adjustedURI.Path
41 if !strings.HasPrefix(path, "/") {
42 path = "/" + path
43 }
44 if adjustedURI.Scheme == "gemini" {
45 resolvedURI = "/gemini/" + adjustedURI.Host + path
46 } else if adjustedURI.Scheme == "gopher" {
47 resolvedURI = "/gopher/" + adjustedURI.Host + path
48 } else {
49 resolvedURI = adjustedURI.String()
50 }
51 }
52
53 return
54}
55
56func GeminiHandler(tpl *template.Template, robotsdata *robotstxt.RobotsData, assetList AssetList, robotsdebug bool) http.HandlerFunc {
57 return func(w http.ResponseWriter, req *http.Request) {
58 agent := req.UserAgent()
59 path := strings.TrimPrefix(req.URL.Path, "/gemini/")
60
61 if robotsdata != nil && robotsdebug && !robotsdata.TestAgent(path, agent) {
62 log.Printf("UserAgent %s ignored robots.txt", agent)
63 }
64
65 parts := strings.Split(path, "/")
66 hostport := parts[0]
67
68 if len(hostport) == 0 {
69 http.Redirect(w, req, "/", http.StatusFound)
70 return
71 }
72
73 title := hostport
74
75 var qs string
76
77 if req.URL.RawQuery != "" {
78 qs = fmt.Sprintf("?%s", url.QueryEscape(req.URL.RawQuery))
79 }
80
81 uri, err := url.QueryUnescape(strings.Join(parts[1:], "/"))
82 if err != nil {
83 if e := tpl.Execute(w, TemplateVariables{
84 Title: title,
85 URI: hostport,
86 Assets: assetList,
87 RawText: fmt.Sprintf("Error: %s", err),
88 Error: true,
89 Protocol: "gemini",
90 }); e != nil {
91 log.Println("Template error: " + e.Error())
92 log.Println(err.Error())
93 }
94 return
95 }
96
97 if uri != "" {
98 title = fmt.Sprintf("%s/%s", hostport, uri)
99 }
100
101 res, err := libgemini.Get(
102 fmt.Sprintf(
103 "gemini://%s/%s%s",
104 hostport,
105 uri,
106 qs,
107 ),
108 )
109
110 if err != nil {
111 if e := tpl.Execute(w, TemplateVariables{
112 Title: title,
113 URI: fmt.Sprintf("%s/%s", hostport, uri),
114 Assets: assetList,
115 RawText: fmt.Sprintf("Error: %s", err),
116 Error: true,
117 Protocol: "gemini",
118 }); e != nil {
119 log.Println("Template error: " + e.Error())
120 log.Println(err.Error())
121 }
122 return
123 }
124
125 if int(res.Header.Status/10) == 3 {
126 baseURL, err := url.Parse(fmt.Sprintf(
127 "gemini://%s/%s",
128 hostport,
129 uri,
130 ))
131 if err != nil {
132 if e := tpl.Execute(w, TemplateVariables{
133 Title: title,
134 URI: fmt.Sprintf("%s/%s", hostport, uri),
135 Assets: assetList,
136 RawText: fmt.Sprintf("Error: %s", err),
137 Error: true,
138 Protocol: "gemini",
139 }); e != nil {
140 log.Println("Template error: " + e.Error())
141 log.Println(err.Error())
142 }
143 return
144 }
145
146 http.Redirect(w, req, resolveURI(res.Header.Meta, baseURL), http.StatusFound)
147 return
148 }
149
150 if int(res.Header.Status/10) != 2 {
151 if err := tpl.Execute(w, TemplateVariables{
152 Title: title,
153 URI: fmt.Sprintf("%s/%s", hostport, uri),
154 Assets: assetList,
155 RawText: fmt.Sprintf("Error %d: %s", res.Header.Status, res.Header.Meta),
156 Error: true,
157 Protocol: "gemini",
158 }); err != nil {
159 log.Println("Template error: " + err.Error())
160 }
161 return
162 }
163
164 if strings.HasPrefix(res.Header.Meta, "text/") {
165 buf := new(bytes.Buffer)
166
167 _, params, err := mime.ParseMediaType(res.Header.Meta)
168 if err != nil {
169 buf.ReadFrom(res.Body)
170 } else {
171 encoding, _ := charset.Lookup(params["charset"])
172 readbuf := new(bytes.Buffer)
173 readbuf.ReadFrom(res.Body)
174
175 writer := transform.NewWriter(buf, encoding.NewDecoder())
176 writer.Write(readbuf.Bytes())
177 writer.Close()
178 }
179
180 var (
181 rawText string
182 items []Item
183 )
184
185 if strings.HasPrefix(res.Header.Meta, libgemini.MIME_GEMINI) {
186 items = parseGeminiDocument(buf, uri, hostport)
187 } else {
188 rawText = buf.String()
189 }
190
191 if err := tpl.Execute(w, TemplateVariables{
192 Title: title,
193 URI: fmt.Sprintf("%s/%s", hostport, uri),
194 Assets: assetList,
195 Lines: items,
196 RawText: rawText,
197 Protocol: "gemini",
198 }); err != nil {
199 log.Println("Template error: " + err.Error())
200 }
201 } else {
202 io.Copy(w, res.Body)
203 }
204 }
205}
diff --git a/internal/port/gopher.go b/internal/port/gopher.go
new file mode 100644
index 0000000..ebeb213
--- /dev/null
+++ b/internal/port/gopher.go
@@ -0,0 +1,217 @@
1package port
2
3import (
4 "bytes"
5 "fmt"
6 "html/template"
7 "io"
8 "log"
9 "net"
10 "net/http"
11 "net/url"
12 "strings"
13
14 "git.vulpes.one/Feuerfuchs/port/port/libgopher"
15
16 "github.com/davidbyttow/govips/pkg/vips"
17 "github.com/temoto/robotstxt"
18)
19
20type Item struct {
21 Link template.URL
22 Type string
23 Text string
24}
25
26func renderGopherDirectory(w http.ResponseWriter, tpl *template.Template, assetList AssetList, uri string, hostport string, d libgopher.Directory) error {
27 var title string
28
29 out := make([]Item, len(d.Items))
30
31 for i, x := range d.Items {
32 if x.Type == libgopher.INFO && x.Selector == "TITLE" {
33 title = x.Description
34 continue
35 }
36
37 tr := Item{
38 Text: x.Description,
39 Type: x.Type.String(),
40 }
41
42 if x.Type == libgopher.INFO {
43 out[i] = tr
44 continue
45 }
46
47 if strings.HasPrefix(x.Selector, "URL:") || strings.HasPrefix(x.Selector, "/URL:") {
48 link := strings.TrimPrefix(strings.TrimPrefix(x.Selector, "/"), "URL:")
49 if strings.HasPrefix(link, "gemini://") {
50 link = fmt.Sprintf(
51 "/gemini/%s",
52 strings.TrimPrefix(link, "gemini://"),
53 )
54 } else if strings.HasPrefix(link, "gopher://") {
55 link = fmt.Sprintf(
56 "/gopher/%s",
57 strings.TrimPrefix(link, "gopher://"),
58 )
59 }
60 tr.Link = template.URL(link)
61 } else {
62 var linkHostport string
63 if x.Port != "70" {
64 linkHostport = net.JoinHostPort(x.Host, x.Port)
65 } else {
66 linkHostport = x.Host
67 }
68
69 path := url.PathEscape(x.Selector)
70 path = strings.Replace(path, "%2F", "/", -1)
71 tr.Link = template.URL(
72 fmt.Sprintf(
73 "/gopher/%s/%s%s",
74 linkHostport,
75 string(byte(x.Type)),
76 path,
77 ),
78 )
79 }
80
81 out[i] = tr
82 }
83
84 if title == "" {
85 if uri != "" {
86 title = fmt.Sprintf("%s/%s", hostport, uri)
87 } else {
88 title = hostport
89 }
90 }
91
92 return tpl.Execute(w, TemplateVariables{
93 Title: title,
94 URI: fmt.Sprintf("%s/%s", hostport, uri),
95 Assets: assetList,
96 Lines: out,
97 Protocol: "gopher",
98 })
99}
100
101// GopherHandler returns a Handler that proxies requests
102// to the specified Gopher server as denoated by the first argument
103// to the request path and renders the content using the provided template.
104// The optional robots parameters points to a robotstxt.RobotsData struct
105// to test user agents against a configurable robotst.txt file.
106func GopherHandler(tpl *template.Template, robotsdata *robotstxt.RobotsData, assetList AssetList, robotsdebug bool) http.HandlerFunc {
107 return func(w http.ResponseWriter, req *http.Request) {
108 agent := req.UserAgent()
109 path := strings.TrimPrefix(req.URL.Path, "/gopher/")
110
111 if robotsdata != nil && robotsdebug && !robotsdata.TestAgent(path, agent) {
112 log.Printf("UserAgent %s ignored robots.txt", agent)
113 }
114
115 parts := strings.Split(path, "/")
116 hostport := parts[0]
117
118 if len(hostport) == 0 {
119 http.Redirect(w, req, "/", http.StatusFound)
120 return
121 }
122
123 title := hostport
124
125 var qs string
126
127 if req.URL.RawQuery != "" {
128 qs = fmt.Sprintf("?%s", url.QueryEscape(req.URL.RawQuery))
129 }
130
131 uri, err := url.QueryUnescape(strings.Join(parts[1:], "/"))
132 if err != nil {
133 if e := tpl.Execute(w, TemplateVariables{
134 Title: title,
135 URI: hostport,
136 Assets: assetList,
137 RawText: fmt.Sprintf("Error: %s", err),
138 Error: true,
139 Protocol: "gopher",
140 }); e != nil {
141 log.Println("Template error: " + e.Error())
142 log.Println(err.Error())
143 }
144 return
145 }
146
147 if uri != "" {
148 title = fmt.Sprintf("%s/%s", hostport, uri)
149 }
150
151 res, err := libgopher.Get(
152 fmt.Sprintf(
153 "gopher://%s/%s%s",
154 hostport,
155 uri,
156 qs,
157 ),
158 )
159
160 if err != nil {
161 if e := tpl.Execute(w, TemplateVariables{
162 Title: title,
163 URI: fmt.Sprintf("%s/%s", hostport, uri),
164 Assets: assetList,
165 RawText: fmt.Sprintf("Error: %s", err),
166 Error: true,
167 Protocol: "gopher",
168 }); e != nil {
169 log.Println("Template error: " + e.Error())
170 }
171 return
172 }
173
174 if res.Body != nil {
175 if len(parts) < 2 {
176 io.Copy(w, res.Body)
177 } else if strings.HasPrefix(parts[1], "0") && !strings.HasSuffix(uri, ".xml") && !strings.HasSuffix(uri, ".asc") {
178 buf := new(bytes.Buffer)
179 buf.ReadFrom(res.Body)
180
181 if err := tpl.Execute(w, TemplateVariables{
182 Title: title,
183 URI: fmt.Sprintf("%s/%s", hostport, uri),
184 Assets: assetList,
185 RawText: buf.String(),
186 Protocol: "gopher",
187 }); err != nil {
188 log.Println("Template error: " + err.Error())
189 }
190 } else if strings.HasPrefix(parts[1], "T") {
191 _, _, err = vips.NewTransform().
192 Load(res.Body).
193 ResizeStrategy(vips.ResizeStrategyAuto).
194 ResizeWidth(160).
195 Quality(75).
196 Output(w).
197 Apply()
198 } else {
199 io.Copy(w, res.Body)
200 }
201 } else {
202 if err := renderGopherDirectory(w, tpl, assetList, uri, hostport, res.Dir); err != nil {
203 if e := tpl.Execute(w, TemplateVariables{
204 Title: title,
205 URI: fmt.Sprintf("%s/%s", hostport, uri),
206 Assets: assetList,
207 RawText: fmt.Sprintf("Error: %s", err),
208 Error: true,
209 Protocol: "gopher",
210 }); e != nil {
211 log.Println("Template error: " + e.Error())
212 log.Println(e.Error())
213 }
214 }
215 }
216 }
217}
diff --git a/internal/port/main.go b/internal/port/main.go
new file mode 100644
index 0000000..5cdd794
--- /dev/null
+++ b/internal/port/main.go
@@ -0,0 +1,301 @@
1package port
2
3import (
4 "crypto/md5"
5 "fmt"
6 "html"
7 "html/template"
8 "io/ioutil"
9 "log"
10 "net/http"
11 "regexp"
12 "strings"
13
14 "github.com/NYTimes/gziphandler"
15 "github.com/davidbyttow/govips/pkg/vips"
16 "github.com/gobuffalo/packr/v2"
17 "github.com/temoto/robotstxt"
18)
19
20type AssetList struct {
21 Style string
22 JS string
23 FontW string
24 FontW2 string
25 PropFontW string
26 PropFontW2 string
27}
28
29type TemplateVariables struct {
30 Title string
31 URI string
32 Assets AssetList
33 RawText string
34 Lines []Item
35 Error bool
36 Protocol string
37}
38
39func DefaultHandler(tpl *template.Template, startpagetext string, assetList AssetList) http.HandlerFunc {
40 return func(w http.ResponseWriter, req *http.Request) {
41 if err := tpl.Execute(w, TemplateVariables{
42 Title: "Gopher/Gemini proxy",
43 Assets: assetList,
44 RawText: startpagetext,
45 Protocol: "startpage",
46 }); err != nil {
47 log.Println("Template error: " + err.Error())
48 }
49 }
50}
51
52// RobotsTxtHandler returns the contents of the robots.txt file
53// if configured and valid.
54func RobotsTxtHandler(robotstxtdata []byte) http.HandlerFunc {
55 return func(w http.ResponseWriter, req *http.Request) {
56 if robotstxtdata == nil {
57 http.Error(w, "Not Found", http.StatusNotFound)
58 return
59 }
60
61 w.Header().Set("Content-Type", "text/plain")
62 w.Write(robotstxtdata)
63 }
64}
65
66func FaviconHandler(favicondata []byte) http.HandlerFunc {
67 return func(w http.ResponseWriter, req *http.Request) {
68 if favicondata == nil {
69 http.Error(w, "Not Found", http.StatusNotFound)
70 return
71 }
72
73 w.Header().Set("Content-Type", "image/vnd.microsoft.icon")
74 w.Header().Set("Cache-Control", "max-age=2592000")
75 w.Write(favicondata)
76 }
77}
78
79func StyleHandler(styledata []byte) http.HandlerFunc {
80 return func(w http.ResponseWriter, req *http.Request) {
81 w.Header().Set("Content-Type", "text/css")
82 w.Header().Set("Cache-Control", "max-age=2592000")
83 w.Write(styledata)
84 }
85}
86
87func JavaScriptHandler(jsdata []byte) http.HandlerFunc {
88 return func(w http.ResponseWriter, req *http.Request) {
89 w.Header().Set("Content-Type", "text/javascript")
90 w.Header().Set("Cache-Control", "max-age=2592000")
91 w.Write(jsdata)
92 }
93}
94
95func FontHandler(woff2 bool, fontdata []byte) http.HandlerFunc {
96 return func(w http.ResponseWriter, req *http.Request) {
97 if fontdata == nil {
98 http.Error(w, "Not Found", http.StatusNotFound)
99 return
100 }
101
102 if woff2 {
103 w.Header().Set("Content-Type", "font/woff2")
104 } else {
105 w.Header().Set("Content-Type", "font/woff")
106 }
107 w.Header().Set("Cache-Control", "max-age=2592000")
108
109 w.Write(fontdata)
110 }
111}
112
113// ListenAndServe creates a listening HTTP server bound to
114// the interface specified by bind and sets up a Gopher to HTTP
115// proxy proxying requests as requested and by default will prozy
116// to a Gopher server address specified by uri if no servers is
117// specified by the request. The robots argument is a pointer to
118// a robotstxt.RobotsData struct for testing user agents against
119// a configurable robots.txt file.
120func ListenAndServe(bind, startpagefile string, robotsfile string, robotsdebug bool, vipsconcurrency int) error {
121 box := packr.New("assets", "../assets")
122
123 //
124 // Robots
125
126 var robotsdata *robotstxt.RobotsData
127
128 robotstxtdata, err := ioutil.ReadFile(robotsfile)
129 if err != nil {
130 log.Printf("error reading robots.txt: %s", err)
131 robotstxtdata = nil
132 } else {
133 robotsdata, err = robotstxt.FromBytes(robotstxtdata)
134 if err != nil {
135 log.Printf("error reading robots.txt: %s", err)
136 robotstxtdata = nil
137 }
138 }
139
140 //
141 // Fonts
142
143 fontdataw, err := box.Find("iosevka-term-ss03-regular.woff")
144 if err != nil {
145 fontdataw = []byte{}
146 }
147 fontwAsset := fmt.Sprintf("/iosevka-term-ss03-regular-%x.woff", md5.Sum(fontdataw))
148
149 fontdataw2, err := box.Find("iosevka-term-ss03-regular.woff2")
150 if err != nil {
151 fontdataw2 = []byte{}
152 }
153 fontw2Asset := fmt.Sprintf("/iosevka-term-ss03-regular-%x.woff2", md5.Sum(fontdataw2))
154
155 propfontdataw, err := box.Find("iosevka-aile-regular.woff")
156 if err != nil {
157 propfontdataw = []byte{}
158 }
159 propfontwAsset := fmt.Sprintf("/iosevka-aile-regular-%x.woff", md5.Sum(propfontdataw))
160
161 propfontdataw2, err := box.Find("iosevka-aile-regular.woff2")
162 if err != nil {
163 propfontdataw2 = []byte{}
164 }
165 propfontw2Asset := fmt.Sprintf("/iosevka-aile-regular-%x.woff2", md5.Sum(propfontdataw2))
166
167 //
168 // Stylesheet
169
170 styledata, err := box.Find("style.css")
171 if err != nil {
172 styledata = []byte{}
173 }
174 styleAsset := fmt.Sprintf("/style-%x.css", md5.Sum(styledata))
175
176 //
177 // JavaScript
178
179 jsdata, err := box.Find("main.js")
180 if err != nil {
181 jsdata = []byte{}
182 }
183 jsAsset := fmt.Sprintf("/main-%x.js", md5.Sum(jsdata))
184
185 //
186 // Favicon
187
188 favicondata, err := box.Find("favicon.ico")
189 if err != nil {
190 favicondata = []byte{}
191 }
192
193 //
194 // Start page text
195
196 startpagedata, err := ioutil.ReadFile(startpagefile)
197 if err != nil {
198 startpagedata, err = box.Find("startpage.txt")
199 if err != nil {
200 startpagedata = []byte{}
201 }
202 }
203 startpagetext := string(startpagedata)
204
205 //
206 //
207
208 var allFiles []string
209 files, err := ioutil.ReadDir("./tpl")
210 if err != nil {
211 fmt.Println(err)
212 }
213 for _, file := range files {
214 filename := file.Name()
215 if strings.HasSuffix(filename, ".html") {
216 allFiles = append(allFiles, "./tpl/"+filename)
217 }
218 }
219
220 templates, err = template.ParseFiles(allFiles...)
221
222 //
223
224 funcMap := template.FuncMap{
225 "safeHtml": func(s string) template.HTML {
226 return template.HTML(s)
227 },
228 "safeCss": func(s string) template.CSS {
229 return template.CSS(s)
230 },
231 "safeJs": func(s string) template.JS {
232 return template.JS(s)
233 },
234 "HTMLEscape": func(s string) string {
235 return html.EscapeString(s)
236 },
237 "split": strings.Split,
238 "last": func(s []string) string {
239 return s[len(s)-1]
240 },
241 "pop": func(s []string) []string {
242 return s[:len(s)-1]
243 },
244 "replace": func(pattern, output string, input interface{}) string {
245 var re = regexp.MustCompile(pattern)
246 var inputStr = fmt.Sprintf("%v", input)
247 return re.ReplaceAllString(inputStr, output)
248 },
249 "trimLeftChar": func(s string) string {
250 for i := range s {
251 if i > 0 {
252 return s[i:]
253 }
254 }
255 return s[:0]
256 },
257 "hasPrefix": func(s string, prefix string) bool {
258 return strings.HasPrefix(s, prefix)
259 },
260 "title": func(s string) string {
261 return strings.Title(s)
262 },
263 }
264
265 //
266
267 startpageTpl := templates.Lookup("startpage.html").Funcs(funcMap)
268 geminiTpl := templates.Lookup("gemini.html").Funcs(funcMap)
269 gopherTpl := templates.Lookup("gopher.html").Funcs(funcMap)
270
271 //
272 //
273
274 vips.Startup(&vips.Config{
275 ConcurrencyLevel: vipsconcurrency,
276 })
277
278 assets := AssetList{
279 Style: styleAsset,
280 JS: jsAsset,
281 FontW: fontwAsset,
282 FontW2: fontw2Asset,
283 PropFontW: propfontwAsset,
284 PropFontW2: propfontw2Asset,
285 }
286
287 http.Handle("/", gziphandler.GzipHandler(DefaultHandler(startpageTpl, startpagetext, assets)))
288 http.Handle("/gopher/", gziphandler.GzipHandler(GopherHandler(gopherTpl, robotsdata, assets, robotsdebug)))
289 http.Handle("/gemini/", gziphandler.GzipHandler(GeminiHandler(geminiTpl, robotsdata, assets, robotsdebug)))
290 http.Handle("/robots.txt", gziphandler.GzipHandler(RobotsTxtHandler(robotstxtdata)))
291 http.Handle("/favicon.ico", gziphandler.GzipHandler(FaviconHandler(favicondata)))
292 http.Handle(styleAsset, gziphandler.GzipHandler(StyleHandler(styledata)))
293 http.Handle(jsAsset, gziphandler.GzipHandler(JavaScriptHandler(jsdata)))
294 http.HandleFunc(fontwAsset, FontHandler(false, fontdataw))
295 http.HandleFunc(fontw2Asset, FontHandler(true, fontdataw2))
296 http.HandleFunc(propfontwAsset, FontHandler(false, propfontdataw))
297 http.HandleFunc(propfontw2Asset, FontHandler(true, propfontdataw2))
298 //http.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("assets/"))))
299
300 return http.ListenAndServe(bind, nil)
301}
diff --git a/internal/port/tpl/gemini.html b/internal/port/tpl/gemini.html
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/internal/port/tpl/gemini.html
diff --git a/internal/port/tpl/gopher.html b/internal/port/tpl/gopher.html
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/internal/port/tpl/gopher.html
diff --git a/internal/port/tpl/startpage.html b/internal/port/tpl/startpage.html
new file mode 100644
index 0000000..8482a6f
--- /dev/null
+++ b/internal/port/tpl/startpage.html
@@ -0,0 +1,120 @@
1<!doctype html>
2<html>
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1" />
6 <title>{{ .Title }}{{ if ne .Protocol "startpage" }} - {{ .Protocol | title }} proxy{{ end }}</title>
7 <link rel="stylesheet" href="{{ .Assets.Style }}" />
8 <style>
9 @font-face {
10 font-family: 'Iosevka Term SS03';
11 font-style: normal;
12 font-weight: normal;
13 src: url('{{ .Assets.FontW2 }}') format('woff2'),
14 url('{{ .Assets.FontW }}') format('woff');
15 }
16 @font-face {
17 font-family: 'Iosevka Aile';
18 font-style: normal;
19 font-weight: normal;
20 src: url('{{ .Assets.PropFontW2 }}') format('woff2'),
21 url('{{ .Assets.PropFontW }}') format('woff');
22 }
23 </style>
24 </head>
25 <body class="{{ if not .Lines }}is-plain{{ end }}">
26 <header class="header header-base">
27 <div class="location">
28 <a class="location__prefix">{{ .Protocol }}://</a><a class="location__prefix location__prefix--mobile">://</a>
29
30 {{- if .URI -}}
31 {{- $page := . -}}
32 {{- $href := printf "/%s" .Protocol -}}
33 {{- $uriParts := split .URI "/" -}}
34
35 {{- $uriLast := $uriParts | last -}}
36 {{- $uriParts = $uriParts | pop -}}
37 {{- if eq $uriLast "" -}}
38 {{- $uriLast = $uriParts | last -}}
39 {{- $uriParts = $uriParts | pop -}}
40 {{- end -}}
41
42 {{- range $i, $part := $uriParts -}}
43 {{- if and (eq $page.Protocol "gopher") (eq $i 1) -}}
44 {{- $href = printf "%s/1" $href -}}
45 {{- $part = $part | trimLeftChar -}}
46 {{- if not (eq $part "") -}}
47 {{- $href = printf "%s/%s" $href $part -}}
48 <span class="location__slash">/</span><a href="{{ $href }}/" class="location__uripart">{{ $part }}</a>
49 {{- end -}}
50 {{- else -}}
51 {{- $href = printf "%s/%s" $href . -}}
52 {{- if ne $i 0 -}}
53 <span class="location__slash">/</span>
54 {{- end -}}
55 <a href="{{ $href }}/" class="location__uripart">{{ . }}</a>
56 {{- end -}}
57 {{- end -}}
58 {{- if ne (len $uriParts) 0 -}}
59 <span class="location__slash">/</span>
60 {{- end -}}
61 {{- if and (eq $page.Protocol "gopher") (eq (len $uriParts) 1) -}}
62 {{- $uriLast = $uriLast | trimLeftChar -}}
63 {{- end -}}
64 <span class="location__uripart">{{ $uriLast }}</span>
65 {{- end -}}
66 </div>
67 <div class="actions">
68 {{- if and (not .Lines) (not .Error) (eq .Protocol "gopher") -}}
69 <div class="action"><a href="/gopher/{{ .URI | replace "^([^/]*)/0" "$1/9" }}">View raw</a></div>
70 {{- end -}}
71 <div class="action"><button class="settings-btn">Settings</button></div>
72 </div>
73 </header>
74 <main class="wrap">
75 <pre class="content content--has-monospace-font{{ if .Lines }} content--has-type-annotations{{ end }}">
76 {{- if .Lines -}}
77 {{- $content := "" -}}
78 {{- range .Lines -}}
79 {{- if ne $content "" -}}
80 {{- $content = printf "%s\n" $content -}}
81 {{- end -}}
82 {{- if .Link -}}
83 {{- $content = printf "%s%s" $content (printf "<span class=\"type-annotation\">%s </span><a class=\"link link--%s\" href=\"%s\">%s</a>" .Type .Type .Link (.Text | HTMLEscape)) -}}
84 {{- else -}}
85 {{- $content = printf "%s%s" $content (printf "<span class=\"type-annotation\"> </span>%s" (.Text | HTMLEscape)) -}}
86 {{- end -}}
87 {{- end -}}
88 {{- $content | safeHtml -}}
89 {{- else -}}
90 {{- .RawText -}}
91 {{- end -}}
92 </pre>
93 </main>
94 <aside class="modal modal--settings">
95 <div class="modal__content">
96 <header class="modal__head header-base">
97 <h1 class="modal__title">Settings</h1>
98 <button class="modal__close-btn">Close</button>
99 </header>
100 <div class="setting setting--word-wrap">
101 <strong class="setting__label">Wrap wide content</strong>
102 <button class="setting__value">[N/A]</button>
103 </div>
104 <div class="setting setting--monospace-font">
105 <strong class="setting__label">Monospace font</strong>
106 <button class="setting__value">[N/A]</button>
107 </div>
108 <div class="setting setting--image-previews">
109 <strong class="setting__label">Image thumbnails</strong>
110 <button class="setting__value">[N/A]</button>
111 </div>
112 <div class="setting setting--clickable-plain-links">
113 <strong class="setting__label">Clickable links in text files</strong>
114 <button class="setting__value">[N/A]</button>
115 </div>
116 </div>
117 </aside>
118 <script src="{{ .Assets.JS }}"></script>
119 </body>
120</html>