1
2
3
4
5
6
7 package http
8
9 import (
10 "errors"
11 "fmt"
12 "io"
13 "io/fs"
14 "mime"
15 "mime/multipart"
16 "net/textproto"
17 "net/url"
18 "os"
19 "path"
20 "path/filepath"
21 "sort"
22 "strconv"
23 "strings"
24 "time"
25 )
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43 type Dir string
44
45
46
47
48 func mapOpenError(originalErr error, name string, sep rune, stat func(string) (fs.FileInfo, error)) error {
49 if errors.Is(originalErr, fs.ErrNotExist) || errors.Is(originalErr, fs.ErrPermission) {
50 return originalErr
51 }
52
53 parts := strings.Split(name, string(sep))
54 for i := range parts {
55 if parts[i] == "" {
56 continue
57 }
58 fi, err := stat(strings.Join(parts[:i+1], string(sep)))
59 if err != nil {
60 return originalErr
61 }
62 if !fi.IsDir() {
63 return fs.ErrNotExist
64 }
65 }
66 return originalErr
67 }
68
69
70
71 func (d Dir) Open(name string) (File, error) {
72 path := path.Clean("/" + name)[1:]
73 if path == "" {
74 path = "."
75 }
76 path, err := filepath.Localize(path)
77 if err != nil {
78 return nil, errors.New("http: invalid or unsafe file path")
79 }
80 dir := string(d)
81 if dir == "" {
82 dir = "."
83 }
84 fullName := filepath.Join(dir, path)
85 f, err := os.Open(fullName)
86 if err != nil {
87 return nil, mapOpenError(err, fullName, filepath.Separator, os.Stat)
88 }
89 return f, nil
90 }
91
92
93
94
95
96
97
98
99 type FileSystem interface {
100 Open(name string) (File, error)
101 }
102
103
104
105
106
107 type File interface {
108 io.Closer
109 io.Reader
110 io.Seeker
111 Readdir(count int) ([]fs.FileInfo, error)
112 Stat() (fs.FileInfo, error)
113 }
114
115 type anyDirs interface {
116 len() int
117 name(i int) string
118 isDir(i int) bool
119 }
120
121 type fileInfoDirs []fs.FileInfo
122
123 func (d fileInfoDirs) len() int { return len(d) }
124 func (d fileInfoDirs) isDir(i int) bool { return d[i].IsDir() }
125 func (d fileInfoDirs) name(i int) string { return d[i].Name() }
126
127 type dirEntryDirs []fs.DirEntry
128
129 func (d dirEntryDirs) len() int { return len(d) }
130 func (d dirEntryDirs) isDir(i int) bool { return d[i].IsDir() }
131 func (d dirEntryDirs) name(i int) string { return d[i].Name() }
132
133 func dirList(w ResponseWriter, r *Request, f File) {
134
135
136
137 var dirs anyDirs
138 var err error
139 if d, ok := f.(fs.ReadDirFile); ok {
140 var list dirEntryDirs
141 list, err = d.ReadDir(-1)
142 dirs = list
143 } else {
144 var list fileInfoDirs
145 list, err = f.Readdir(-1)
146 dirs = list
147 }
148
149 if err != nil {
150 logf(r, "http: error reading directory: %v", err)
151 Error(w, "Error reading directory", StatusInternalServerError)
152 return
153 }
154 sort.Slice(dirs, func(i, j int) bool { return dirs.name(i) < dirs.name(j) })
155
156 w.Header().Set("Content-Type", "text/html; charset=utf-8")
157 fmt.Fprintf(w, "<!doctype html>\n")
158 fmt.Fprintf(w, "<meta name=\"viewport\" content=\"width=device-width\">\n")
159 fmt.Fprintf(w, "<pre>\n")
160 for i, n := 0, dirs.len(); i < n; i++ {
161 name := dirs.name(i)
162 if dirs.isDir(i) {
163 name += "/"
164 }
165
166
167
168 url := url.URL{Path: name}
169 fmt.Fprintf(w, "<a href=\"%s\">%s</a>\n", url.String(), htmlReplacer.Replace(name))
170 }
171 fmt.Fprintf(w, "</pre>\n")
172 }
173
174
175
176
177
178 func serveError(w ResponseWriter, text string, code int) {
179 h := w.Header()
180 h.Del("Etag")
181 h.Del("Last-Modified")
182 h.Del("Cache-Control")
183 Error(w, text, code)
184 }
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211 func ServeContent(w ResponseWriter, req *Request, name string, modtime time.Time, content io.ReadSeeker) {
212 sizeFunc := func() (int64, error) {
213 size, err := content.Seek(0, io.SeekEnd)
214 if err != nil {
215 return 0, errSeeker
216 }
217 _, err = content.Seek(0, io.SeekStart)
218 if err != nil {
219 return 0, errSeeker
220 }
221 return size, nil
222 }
223 serveContent(w, req, name, modtime, sizeFunc, content)
224 }
225
226
227
228
229
230 var errSeeker = errors.New("seeker can't seek")
231
232
233
234 var errNoOverlap = errors.New("invalid range: failed to overlap")
235
236
237
238
239
240 func serveContent(w ResponseWriter, r *Request, name string, modtime time.Time, sizeFunc func() (int64, error), content io.ReadSeeker) {
241 setLastModified(w, modtime)
242 done, rangeReq := checkPreconditions(w, r, modtime)
243 if done {
244 return
245 }
246
247 code := StatusOK
248
249
250
251 ctypes, haveType := w.Header()["Content-Type"]
252 var ctype string
253 if !haveType {
254 ctype = mime.TypeByExtension(filepath.Ext(name))
255 if ctype == "" {
256
257 var buf [sniffLen]byte
258 n, _ := io.ReadFull(content, buf[:])
259 ctype = DetectContentType(buf[:n])
260 _, err := content.Seek(0, io.SeekStart)
261 if err != nil {
262 serveError(w, "seeker can't seek", StatusInternalServerError)
263 return
264 }
265 }
266 w.Header().Set("Content-Type", ctype)
267 } else if len(ctypes) > 0 {
268 ctype = ctypes[0]
269 }
270
271 size, err := sizeFunc()
272 if err != nil {
273 serveError(w, err.Error(), StatusInternalServerError)
274 return
275 }
276 if size < 0 {
277
278 serveError(w, "negative content size computed", StatusInternalServerError)
279 return
280 }
281
282
283 sendSize := size
284 var sendContent io.Reader = content
285 ranges, err := parseRange(rangeReq, size)
286 switch err {
287 case nil:
288 case errNoOverlap:
289 if size == 0 {
290
291
292
293
294 ranges = nil
295 break
296 }
297 w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size))
298 fallthrough
299 default:
300 serveError(w, err.Error(), StatusRequestedRangeNotSatisfiable)
301 return
302 }
303
304 if sumRangesSize(ranges) > size {
305
306
307
308
309 ranges = nil
310 }
311 switch {
312 case len(ranges) == 1:
313
314
315
316
317
318
319
320
321
322
323
324 ra := ranges[0]
325 if _, err := content.Seek(ra.start, io.SeekStart); err != nil {
326 serveError(w, err.Error(), StatusRequestedRangeNotSatisfiable)
327 return
328 }
329 sendSize = ra.length
330 code = StatusPartialContent
331 w.Header().Set("Content-Range", ra.contentRange(size))
332 case len(ranges) > 1:
333 sendSize = rangesMIMESize(ranges, ctype, size)
334 code = StatusPartialContent
335
336 pr, pw := io.Pipe()
337 mw := multipart.NewWriter(pw)
338 w.Header().Set("Content-Type", "multipart/byteranges; boundary="+mw.Boundary())
339 sendContent = pr
340 defer pr.Close()
341 go func() {
342 for _, ra := range ranges {
343 part, err := mw.CreatePart(ra.mimeHeader(ctype, size))
344 if err != nil {
345 pw.CloseWithError(err)
346 return
347 }
348 if _, err := content.Seek(ra.start, io.SeekStart); err != nil {
349 pw.CloseWithError(err)
350 return
351 }
352 if _, err := io.CopyN(part, content, ra.length); err != nil {
353 pw.CloseWithError(err)
354 return
355 }
356 }
357 mw.Close()
358 pw.Close()
359 }()
360 }
361
362 w.Header().Set("Accept-Ranges", "bytes")
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389 if len(ranges) > 0 || w.Header().Get("Content-Encoding") == "" {
390 w.Header().Set("Content-Length", strconv.FormatInt(sendSize, 10))
391 }
392 w.WriteHeader(code)
393
394 if r.Method != "HEAD" {
395 io.CopyN(w, sendContent, sendSize)
396 }
397 }
398
399
400
401
402 func scanETag(s string) (etag string, remain string) {
403 s = textproto.TrimString(s)
404 start := 0
405 if strings.HasPrefix(s, "W/") {
406 start = 2
407 }
408 if len(s[start:]) < 2 || s[start] != '"' {
409 return "", ""
410 }
411
412
413 for i := start + 1; i < len(s); i++ {
414 c := s[i]
415 switch {
416
417 case c == 0x21 || c >= 0x23 && c <= 0x7E || c >= 0x80:
418 case c == '"':
419 return s[:i+1], s[i+1:]
420 default:
421 return "", ""
422 }
423 }
424 return "", ""
425 }
426
427
428
429 func etagStrongMatch(a, b string) bool {
430 return a == b && a != "" && a[0] == '"'
431 }
432
433
434
435 func etagWeakMatch(a, b string) bool {
436 return strings.TrimPrefix(a, "W/") == strings.TrimPrefix(b, "W/")
437 }
438
439
440
441 type condResult int
442
443 const (
444 condNone condResult = iota
445 condTrue
446 condFalse
447 )
448
449 func checkIfMatch(w ResponseWriter, r *Request) condResult {
450 im := r.Header.Get("If-Match")
451 if im == "" {
452 return condNone
453 }
454 for {
455 im = textproto.TrimString(im)
456 if len(im) == 0 {
457 break
458 }
459 if im[0] == ',' {
460 im = im[1:]
461 continue
462 }
463 if im[0] == '*' {
464 return condTrue
465 }
466 etag, remain := scanETag(im)
467 if etag == "" {
468 break
469 }
470 if etagStrongMatch(etag, w.Header().get("Etag")) {
471 return condTrue
472 }
473 im = remain
474 }
475
476 return condFalse
477 }
478
479 func checkIfUnmodifiedSince(r *Request, modtime time.Time) condResult {
480 ius := r.Header.Get("If-Unmodified-Since")
481 if ius == "" || isZeroTime(modtime) {
482 return condNone
483 }
484 t, err := ParseTime(ius)
485 if err != nil {
486 return condNone
487 }
488
489
490
491 modtime = modtime.Truncate(time.Second)
492 if ret := modtime.Compare(t); ret <= 0 {
493 return condTrue
494 }
495 return condFalse
496 }
497
498 func checkIfNoneMatch(w ResponseWriter, r *Request) condResult {
499 inm := r.Header.get("If-None-Match")
500 if inm == "" {
501 return condNone
502 }
503 buf := inm
504 for {
505 buf = textproto.TrimString(buf)
506 if len(buf) == 0 {
507 break
508 }
509 if buf[0] == ',' {
510 buf = buf[1:]
511 continue
512 }
513 if buf[0] == '*' {
514 return condFalse
515 }
516 etag, remain := scanETag(buf)
517 if etag == "" {
518 break
519 }
520 if etagWeakMatch(etag, w.Header().get("Etag")) {
521 return condFalse
522 }
523 buf = remain
524 }
525 return condTrue
526 }
527
528 func checkIfModifiedSince(r *Request, modtime time.Time) condResult {
529 if r.Method != "GET" && r.Method != "HEAD" {
530 return condNone
531 }
532 ims := r.Header.Get("If-Modified-Since")
533 if ims == "" || isZeroTime(modtime) {
534 return condNone
535 }
536 t, err := ParseTime(ims)
537 if err != nil {
538 return condNone
539 }
540
541
542 modtime = modtime.Truncate(time.Second)
543 if ret := modtime.Compare(t); ret <= 0 {
544 return condFalse
545 }
546 return condTrue
547 }
548
549 func checkIfRange(w ResponseWriter, r *Request, modtime time.Time) condResult {
550 if r.Method != "GET" && r.Method != "HEAD" {
551 return condNone
552 }
553 ir := r.Header.get("If-Range")
554 if ir == "" {
555 return condNone
556 }
557 etag, _ := scanETag(ir)
558 if etag != "" {
559 if etagStrongMatch(etag, w.Header().Get("Etag")) {
560 return condTrue
561 } else {
562 return condFalse
563 }
564 }
565
566
567 if modtime.IsZero() {
568 return condFalse
569 }
570 t, err := ParseTime(ir)
571 if err != nil {
572 return condFalse
573 }
574 if t.Unix() == modtime.Unix() {
575 return condTrue
576 }
577 return condFalse
578 }
579
580 var unixEpochTime = time.Unix(0, 0)
581
582
583 func isZeroTime(t time.Time) bool {
584 return t.IsZero() || t.Equal(unixEpochTime)
585 }
586
587 func setLastModified(w ResponseWriter, modtime time.Time) {
588 if !isZeroTime(modtime) {
589 w.Header().Set("Last-Modified", modtime.UTC().Format(TimeFormat))
590 }
591 }
592
593 func writeNotModified(w ResponseWriter) {
594
595
596
597
598
599 h := w.Header()
600 delete(h, "Content-Type")
601 delete(h, "Content-Length")
602 delete(h, "Content-Encoding")
603 if h.Get("Etag") != "" {
604 delete(h, "Last-Modified")
605 }
606 w.WriteHeader(StatusNotModified)
607 }
608
609
610
611 func checkPreconditions(w ResponseWriter, r *Request, modtime time.Time) (done bool, rangeHeader string) {
612
613 ch := checkIfMatch(w, r)
614 if ch == condNone {
615 ch = checkIfUnmodifiedSince(r, modtime)
616 }
617 if ch == condFalse {
618 w.WriteHeader(StatusPreconditionFailed)
619 return true, ""
620 }
621 switch checkIfNoneMatch(w, r) {
622 case condFalse:
623 if r.Method == "GET" || r.Method == "HEAD" {
624 writeNotModified(w)
625 return true, ""
626 } else {
627 w.WriteHeader(StatusPreconditionFailed)
628 return true, ""
629 }
630 case condNone:
631 if checkIfModifiedSince(r, modtime) == condFalse {
632 writeNotModified(w)
633 return true, ""
634 }
635 }
636
637 rangeHeader = r.Header.get("Range")
638 if rangeHeader != "" && checkIfRange(w, r, modtime) == condFalse {
639 rangeHeader = ""
640 }
641 return false, rangeHeader
642 }
643
644
645 func serveFile(w ResponseWriter, r *Request, fs FileSystem, name string, redirect bool) {
646 const indexPage = "/index.html"
647
648
649
650
651 if strings.HasSuffix(r.URL.Path, indexPage) {
652 localRedirect(w, r, "./")
653 return
654 }
655
656 f, err := fs.Open(name)
657 if err != nil {
658 msg, code := toHTTPError(err)
659 serveError(w, msg, code)
660 return
661 }
662 defer f.Close()
663
664 d, err := f.Stat()
665 if err != nil {
666 msg, code := toHTTPError(err)
667 serveError(w, msg, code)
668 return
669 }
670
671 if redirect {
672
673
674 url := r.URL.Path
675 if d.IsDir() {
676 if url[len(url)-1] != '/' {
677 localRedirect(w, r, path.Base(url)+"/")
678 return
679 }
680 } else if url[len(url)-1] == '/' {
681 base := path.Base(url)
682 if base == "/" || base == "." {
683
684 msg := "http: attempting to traverse a non-directory"
685 serveError(w, msg, StatusInternalServerError)
686 return
687 }
688 localRedirect(w, r, "../"+base)
689 return
690 }
691 }
692
693 if d.IsDir() {
694 url := r.URL.Path
695
696 if url == "" || url[len(url)-1] != '/' {
697 localRedirect(w, r, path.Base(url)+"/")
698 return
699 }
700
701
702 index := strings.TrimSuffix(name, "/") + indexPage
703 ff, err := fs.Open(index)
704 if err == nil {
705 defer ff.Close()
706 dd, err := ff.Stat()
707 if err == nil {
708 d = dd
709 f = ff
710 }
711 }
712 }
713
714
715 if d.IsDir() {
716 if checkIfModifiedSince(r, d.ModTime()) == condFalse {
717 writeNotModified(w)
718 return
719 }
720 setLastModified(w, d.ModTime())
721 dirList(w, r, f)
722 return
723 }
724
725
726 sizeFunc := func() (int64, error) { return d.Size(), nil }
727 serveContent(w, r, d.Name(), d.ModTime(), sizeFunc, f)
728 }
729
730
731
732
733
734
735 func toHTTPError(err error) (msg string, httpStatus int) {
736 if errors.Is(err, fs.ErrNotExist) {
737 return "404 page not found", StatusNotFound
738 }
739 if errors.Is(err, fs.ErrPermission) {
740 return "403 Forbidden", StatusForbidden
741 }
742
743 return "500 Internal Server Error", StatusInternalServerError
744 }
745
746
747
748 func localRedirect(w ResponseWriter, r *Request, newPath string) {
749 if q := r.URL.RawQuery; q != "" {
750 newPath += "?" + q
751 }
752 w.Header().Set("Location", newPath)
753 w.WriteHeader(StatusMovedPermanently)
754 }
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777 func ServeFile(w ResponseWriter, r *Request, name string) {
778 if containsDotDot(r.URL.Path) {
779
780
781
782
783
784 serveError(w, "invalid URL path", StatusBadRequest)
785 return
786 }
787 dir, file := filepath.Split(name)
788 serveFile(w, r, Dir(dir), file, false)
789 }
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810 func ServeFileFS(w ResponseWriter, r *Request, fsys fs.FS, name string) {
811 if containsDotDot(r.URL.Path) {
812
813
814
815
816
817 serveError(w, "invalid URL path", StatusBadRequest)
818 return
819 }
820 serveFile(w, r, FS(fsys), name, false)
821 }
822
823 func containsDotDot(v string) bool {
824 if !strings.Contains(v, "..") {
825 return false
826 }
827 for _, ent := range strings.FieldsFunc(v, isSlashRune) {
828 if ent == ".." {
829 return true
830 }
831 }
832 return false
833 }
834
835 func isSlashRune(r rune) bool { return r == '/' || r == '\\' }
836
837 type fileHandler struct {
838 root FileSystem
839 }
840
841 type ioFS struct {
842 fsys fs.FS
843 }
844
845 type ioFile struct {
846 file fs.File
847 }
848
849 func (f ioFS) Open(name string) (File, error) {
850 if name == "/" {
851 name = "."
852 } else {
853 name = strings.TrimPrefix(name, "/")
854 }
855 file, err := f.fsys.Open(name)
856 if err != nil {
857 return nil, mapOpenError(err, name, '/', func(path string) (fs.FileInfo, error) {
858 return fs.Stat(f.fsys, path)
859 })
860 }
861 return ioFile{file}, nil
862 }
863
864 func (f ioFile) Close() error { return f.file.Close() }
865 func (f ioFile) Read(b []byte) (int, error) { return f.file.Read(b) }
866 func (f ioFile) Stat() (fs.FileInfo, error) { return f.file.Stat() }
867
868 var errMissingSeek = errors.New("io.File missing Seek method")
869 var errMissingReadDir = errors.New("io.File directory missing ReadDir method")
870
871 func (f ioFile) Seek(offset int64, whence int) (int64, error) {
872 s, ok := f.file.(io.Seeker)
873 if !ok {
874 return 0, errMissingSeek
875 }
876 return s.Seek(offset, whence)
877 }
878
879 func (f ioFile) ReadDir(count int) ([]fs.DirEntry, error) {
880 d, ok := f.file.(fs.ReadDirFile)
881 if !ok {
882 return nil, errMissingReadDir
883 }
884 return d.ReadDir(count)
885 }
886
887 func (f ioFile) Readdir(count int) ([]fs.FileInfo, error) {
888 d, ok := f.file.(fs.ReadDirFile)
889 if !ok {
890 return nil, errMissingReadDir
891 }
892 var list []fs.FileInfo
893 for {
894 dirs, err := d.ReadDir(count - len(list))
895 for _, dir := range dirs {
896 info, err := dir.Info()
897 if err != nil {
898
899 continue
900 }
901 list = append(list, info)
902 }
903 if err != nil {
904 return list, err
905 }
906 if count < 0 || len(list) >= count {
907 break
908 }
909 }
910 return list, nil
911 }
912
913
914
915
916 func FS(fsys fs.FS) FileSystem {
917 return ioFS{fsys}
918 }
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933 func FileServer(root FileSystem) Handler {
934 return &fileHandler{root}
935 }
936
937
938
939
940
941
942
943
944
945 func FileServerFS(root fs.FS) Handler {
946 return FileServer(FS(root))
947 }
948
949 func (f *fileHandler) ServeHTTP(w ResponseWriter, r *Request) {
950 upath := r.URL.Path
951 if !strings.HasPrefix(upath, "/") {
952 upath = "/" + upath
953 r.URL.Path = upath
954 }
955 serveFile(w, r, f.root, path.Clean(upath), true)
956 }
957
958
959 type httpRange struct {
960 start, length int64
961 }
962
963 func (r httpRange) contentRange(size int64) string {
964 return fmt.Sprintf("bytes %d-%d/%d", r.start, r.start+r.length-1, size)
965 }
966
967 func (r httpRange) mimeHeader(contentType string, size int64) textproto.MIMEHeader {
968 return textproto.MIMEHeader{
969 "Content-Range": {r.contentRange(size)},
970 "Content-Type": {contentType},
971 }
972 }
973
974
975
976 func parseRange(s string, size int64) ([]httpRange, error) {
977 if s == "" {
978 return nil, nil
979 }
980 const b = "bytes="
981 if !strings.HasPrefix(s, b) {
982 return nil, errors.New("invalid range")
983 }
984 var ranges []httpRange
985 noOverlap := false
986 for _, ra := range strings.Split(s[len(b):], ",") {
987 ra = textproto.TrimString(ra)
988 if ra == "" {
989 continue
990 }
991 start, end, ok := strings.Cut(ra, "-")
992 if !ok {
993 return nil, errors.New("invalid range")
994 }
995 start, end = textproto.TrimString(start), textproto.TrimString(end)
996 var r httpRange
997 if start == "" {
998
999
1000
1001
1002
1003 if end == "" || end[0] == '-' {
1004 return nil, errors.New("invalid range")
1005 }
1006 i, err := strconv.ParseInt(end, 10, 64)
1007 if i < 0 || err != nil {
1008 return nil, errors.New("invalid range")
1009 }
1010 if i > size {
1011 i = size
1012 }
1013 r.start = size - i
1014 r.length = size - r.start
1015 } else {
1016 i, err := strconv.ParseInt(start, 10, 64)
1017 if err != nil || i < 0 {
1018 return nil, errors.New("invalid range")
1019 }
1020 if i >= size {
1021
1022
1023 noOverlap = true
1024 continue
1025 }
1026 r.start = i
1027 if end == "" {
1028
1029 r.length = size - r.start
1030 } else {
1031 i, err := strconv.ParseInt(end, 10, 64)
1032 if err != nil || r.start > i {
1033 return nil, errors.New("invalid range")
1034 }
1035 if i >= size {
1036 i = size - 1
1037 }
1038 r.length = i - r.start + 1
1039 }
1040 }
1041 ranges = append(ranges, r)
1042 }
1043 if noOverlap && len(ranges) == 0 {
1044
1045 return nil, errNoOverlap
1046 }
1047 return ranges, nil
1048 }
1049
1050
1051 type countingWriter int64
1052
1053 func (w *countingWriter) Write(p []byte) (n int, err error) {
1054 *w += countingWriter(len(p))
1055 return len(p), nil
1056 }
1057
1058
1059
1060 func rangesMIMESize(ranges []httpRange, contentType string, contentSize int64) (encSize int64) {
1061 var w countingWriter
1062 mw := multipart.NewWriter(&w)
1063 for _, ra := range ranges {
1064 mw.CreatePart(ra.mimeHeader(contentType, contentSize))
1065 encSize += ra.length
1066 }
1067 mw.Close()
1068 encSize += int64(w)
1069 return
1070 }
1071
1072 func sumRangesSize(ranges []httpRange) (size int64) {
1073 for _, ra := range ranges {
1074 size += ra.length
1075 }
1076 return
1077 }
1078
View as plain text