]> Sergey Matveev's repositories - godlighty.git/blob - handler.go
Use mtime instead of ctime
[godlighty.git] / handler.go
1 // godlighty -- highly-customizable HTTP, HTTP/2, HTTPS server
2 // Copyright (C) 2021-2024 Sergey Matveev <stargrave@stargrave.org>
3 //
4 // This program is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, version 3 of the License.
7 //
8 // This program is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 // GNU General Public License for more details.
12 //
13 // You should have received a copy of the GNU General Public License
14 // along with this program.  If not, see <http://www.gnu.org/licenses/>.
15
16 package godlighty
17
18 import (
19         "bytes"
20         "compress/gzip"
21         "encoding/base64"
22         "errors"
23         "fmt"
24         "io"
25         "log"
26         "net"
27         "net/http"
28         "net/url"
29         "os"
30         "path"
31         "strconv"
32         "strings"
33         "sync"
34         "time"
35
36         "github.com/klauspost/compress/zstd"
37         "go.stargrave.org/godlighty/meta4"
38         "golang.org/x/net/webdav"
39 )
40
41 const (
42         Index  = "index.html"
43         Readme = "README"
44 )
45
46 var (
47         gzPool = sync.Pool{
48                 New: func() interface{} { return gzip.NewWriter(io.Discard) },
49         }
50         zstdPool = sync.Pool{
51                 New: func() interface{} {
52                         w, err := zstd.NewWriter(
53                                 io.Discard,
54                                 zstd.WithEncoderLevel(zstd.SpeedDefault),
55                         )
56                         if err != nil {
57                                 log.Fatalln(err)
58                         }
59                         return w
60                 },
61         }
62
63         MainHandler Handler
64 )
65
66 func PathWithQuery(u *url.URL) string {
67         if u.RawQuery == "" {
68                 return u.EscapedPath()
69         }
70         return u.EscapedPath() + "?" + u.RawQuery
71 }
72
73 type Handler struct{}
74
75 func (h Handler) Handle(
76         w http.ResponseWriter, r *http.Request,
77         host string, cfg *HostCfg,
78 ) {
79         notFound := func() {
80                 fmt.Printf("%s %s \"%s %+q %s\" %d \"%s\"\n",
81                         r.RemoteAddr, host, r.Method, PathWithQuery(r.URL), r.Proto,
82                         http.StatusNotFound,
83                         r.Header.Get("User-Agent"),
84                 )
85                 http.NotFound(w, r)
86         }
87         w.Header().Set("Server", Version)
88         if cfg == nil {
89                 notFound()
90                 return
91         }
92
93         var username string
94         var err error
95         if cfg.Auth != nil {
96                 username, err = performAuth(w, r, cfg.Auth)
97         }
98         if username != "" {
99                 username = "user:" + username + " "
100         }
101         printErr := func(code int, err error) {
102                 fmt.Printf("%s %s \"%s %+q %s\" %d \"%s\" %s\"%s\"\n",
103                         r.RemoteAddr, host, r.Method, PathWithQuery(r.URL), r.Proto,
104                         code, err.Error(),
105                         username, r.Header.Get("User-Agent"),
106                 )
107         }
108         switch err {
109         case nil:
110                 break
111         case Unauthorized:
112                 printErr(http.StatusUnauthorized, err)
113                 return
114         default:
115                 printErr(http.StatusInternalServerError, err)
116                 http.Error(w, "internal error", http.StatusInternalServerError)
117                 return
118         }
119
120         if (cfg.ECDSATLS != nil && len(cfg.ECDSATLS.ClientCAs) > 0) ||
121                 (cfg.EdDSATLS != nil && len(cfg.EdDSATLS.ClientCAs) > 0) ||
122                 (cfg.GOSTTLS != nil && len(cfg.GOSTTLS.ClientCAs) > 0) {
123                 if r.TLS == nil {
124                         err = errors.New("TLS client authentication required")
125                         printErr(http.StatusForbidden, err)
126                         http.Error(w, err.Error(), http.StatusForbidden)
127                         return
128                 } else {
129                         username += r.TLS.PeerCertificates[0].Subject.String() + " "
130                 }
131         }
132
133         for _, hook := range cfg.Hooks {
134                 if done := hook(w, r); done {
135                         return
136                 }
137         }
138
139         if cfg.Root == "" {
140                 notFound()
141                 return
142         }
143
144         pthOrig := path.Clean(path.Join(cfg.Root, r.URL.Path))
145         pth := pthOrig
146         fi, err := os.Stat(pth)
147         if err != nil {
148                 notFound()
149                 return
150         }
151
152         if cfg.WebDAV && (((r.Method == http.MethodHead) && fi.IsDir()) ||
153                 r.Method == http.MethodOptions ||
154                 r.Method == "PROPFIND") {
155                 dav := webdav.Handler{
156                         FileSystem: webdav.Dir(cfg.Root),
157                         LockSystem: webdav.NewMemLS(),
158                 }
159                 wc := &CountResponseWriter{ResponseWriter: w}
160                 dav.ServeHTTP(wc, r)
161                 fmt.Printf("%s %s \"WebDAV %+q\" %d %d %s\"%s\"\n",
162                         r.RemoteAddr, host, PathWithQuery(r.URL),
163                         wc.Status, wc.Size,
164                         username, r.Header.Get("User-Agent"),
165                 )
166                 return
167         }
168
169         if !(r.Method == "" || r.Method == http.MethodGet || r.Method == http.MethodHead) {
170                 fmt.Printf("%s %s \"%s %+q %s\" %d %s\"%s\"\n",
171                         r.RemoteAddr, host, r.Method, PathWithQuery(r.URL), r.Proto,
172                         http.StatusMethodNotAllowed,
173                         username, r.Header.Get("User-Agent"),
174                 )
175                 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
176                 return
177         }
178
179         var fd *os.File
180         var contentType string
181         var etag string
182 IndexLookuped:
183         if fi.IsDir() {
184                 if cfg.DirList {
185                         entries, err := os.ReadDir(pth)
186                         if err != nil {
187                                 printErr(http.StatusInternalServerError, err)
188                                 http.Error(w, "internal error", http.StatusInternalServerError)
189                                 return
190                         }
191                         fd, err = os.Open(pth)
192                         if err != nil {
193                                 printErr(http.StatusInternalServerError, err)
194                                 http.Error(w, "internal error", http.StatusInternalServerError)
195                                 return
196                         }
197                         etag, err = mtimeETag(fd)
198                         fd.Close()
199                         if err != nil {
200                                 printErr(http.StatusInternalServerError, err)
201                                 http.Error(w, "internal error", http.StatusInternalServerError)
202                                 return
203                         }
204                         var readme []byte
205                         for _, f := range append(cfg.Readmes, Readme) {
206                                 readme, _ = os.ReadFile(path.Join(pth, f))
207                                 if readme != nil {
208                                         break
209                                 }
210                         }
211                         fd, err = dirList(cfg, r.URL.Path, pth, entries, string(readme))
212                         if err != nil {
213                                 printErr(http.StatusInternalServerError, err)
214                                 http.Error(w, "internal error", http.StatusInternalServerError)
215                                 return
216                         }
217                         contentType = "text/html; charset=utf-8"
218                 } else {
219                         for _, index := range append(cfg.Indices, Index) {
220                                 p := path.Join(pth, index)
221                                 if _, err := os.Stat(p); err == nil {
222                                         pth = p
223                                         fi, err = os.Stat(pth)
224                                         if err != nil {
225                                                 notFound()
226                                                 return
227                                         }
228                                         goto IndexLookuped
229                                 }
230                         }
231                         notFound()
232                         return
233                 }
234         }
235
236         if fd == nil {
237                 fd, err = os.Open(pth)
238                 if err != nil {
239                         printErr(http.StatusInternalServerError, err)
240                         http.Error(w, "internal error", http.StatusInternalServerError)
241                         return
242                 }
243                 etag, err = mtimeETag(fd)
244                 if err != nil {
245                         printErr(http.StatusInternalServerError, err)
246                         http.Error(w, "internal error", http.StatusInternalServerError)
247                         return
248                 }
249         }
250         defer fd.Close()
251
252         if meta4fi, err := os.Stat(pth + meta4.Ext); err == nil {
253                 if meta4fi.Size() > meta4.MaxSize {
254                         goto SkipMeta4
255                 }
256                 meta4Raw, err := os.ReadFile(pth + meta4.Ext)
257                 if err != nil {
258                         goto SkipMeta4
259                 }
260                 base := path.Base(pth)
261                 forHTTP, err := meta4.Parse(base, meta4Raw)
262                 if err != nil {
263                         goto SkipMeta4
264                 }
265                 w.Header().Add("Link", "<"+base+meta4.Ext+
266                         `>; rel=describedby; type="application/metalink4+xml"`,
267                 )
268                 for _, u := range forHTTP.URLs {
269                         w.Header().Add("Link", "<"+u+">; rel=duplicate")
270                 }
271                 for name, digest := range forHTTP.Hashes {
272                         w.Header().Add("Digest", name+"="+base64.StdEncoding.EncodeToString(digest))
273                 }
274                 for _, u := range forHTTP.Torrents {
275                         w.Header().Add("Link", "<"+u+`>; rel=describedby; type="application/x-bittorrent"`)
276                 }
277         }
278 SkipMeta4:
279
280         if contentType == "" {
281                 contentType = mediaType(path.Base(pth), cfg.MIMEs)
282         }
283         contentTypeBase := strings.SplitN(contentType, ";", 2)[0]
284         w.Header().Set("Content-Type", contentType)
285
286         if etag != "" {
287                 w.Header().Set("ETag", etag)
288         }
289         var wc http.ResponseWriter
290         var bufCompressed *bytes.Buffer
291         var gz *gzip.Writer
292         var zstdW *zstd.Encoder
293         if _, ok := CompressibleContentTypes[contentTypeBase]; ok {
294                 if strings.Contains(r.Header.Get("Accept-Encoding"), "zstd") {
295                         w.Header().Set("Content-Encoding", "zstd")
296                         zstdW = zstdPool.Get().(*zstd.Encoder)
297                         defer zstdPool.Put(zstdW)
298                         bufCompressed = &bytes.Buffer{}
299                         zstdW.Reset(bufCompressed)
300                         defer zstdW.Close()
301                         wc = &gzipResponseWriter{ResponseWriter: w, Writer: zstdW}
302                 } else if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
303                         w.Header().Set("Content-Encoding", "gzip")
304                         gz = gzPool.Get().(*gzip.Writer)
305                         defer gzPool.Put(gz)
306                         bufCompressed = &bytes.Buffer{}
307                         gz.Reset(bufCompressed)
308                         defer gz.Close()
309                         wc = &gzipResponseWriter{ResponseWriter: w, Writer: gz}
310                 } else {
311                         wc = &CountResponseWriter{ResponseWriter: w}
312                 }
313         } else {
314                 wc = &CountResponseWriter{ResponseWriter: w}
315         }
316         http.ServeContent(wc, r, "", fi.ModTime().UTC().Truncate(time.Second), fd)
317         if bufCompressed != nil {
318                 if gz != nil {
319                         gz.Close()
320                 }
321                 if zstdW != nil {
322                         zstdW.Close()
323                 }
324                 size := bufCompressed.Len()
325                 w.Header().Set("Content-Length", strconv.Itoa(size))
326                 wr := wc.(*gzipResponseWriter)
327                 w.WriteHeader(wr.status)
328                 w.Write(bufCompressed.Bytes())
329                 fmt.Printf("%s %s \"%s %+q %s\" %d %d %s\"%s\"\n",
330                         r.RemoteAddr, host, r.Method, PathWithQuery(r.URL), r.Proto,
331                         wr.status, size,
332                         username, r.Header.Get("User-Agent"),
333                 )
334                 return
335         }
336         wr := wc.(*CountResponseWriter)
337         fmt.Printf("%s %s \"%s %+q %s\" %d %d %s\"%s\"\n",
338                 r.RemoteAddr, host, r.Method, PathWithQuery(r.URL), r.Proto,
339                 wr.Status, wr.Size,
340                 username, r.Header.Get("User-Agent"),
341         )
342 }
343
344 func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
345         if containsDotDot(r.URL.Path) {
346                 http.Error(w, "invalid URL path", http.StatusBadRequest)
347                 return
348         }
349         host, _, err := net.SplitHostPort(r.Host)
350         if err != nil {
351                 host = r.Host
352         }
353         h.Handle(w, r, host, Hosts[host])
354 }