/* Timelinize Copyright (c) 2013 Matthew Holt This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package tlzapp import ( "bytes" "context" "encoding/hex" "encoding/json" "errors" "fmt" "image/jpeg" "io" "mime" "net/http" "os" "path" "strconv" "strings" "sync" "text/template" "time" "github.com/Masterminds/sprig/v3" "github.com/timelinize/timelinize/datasources/media" "github.com/timelinize/timelinize/timeline" "go.n16f.net/thumbhash" ) // serveFrontend serves frontend assets, such as repository resources, // data source images, and the static website files. func (s server) serveFrontend(w http.ResponseWriter, r *http.Request) error { if strings.HasPrefix(r.URL.Path, "/repo/") { // serve timeline item data files, assets, thumbnails, and more return s.handleRepoResource(w, r) } if strings.HasPrefix(r.URL.Path, "/ds-image/") { // serve data source image; not specific to any particular repo return s.dataSourceImage(w, r) } // otherwise, serve static site buf := bufPool.Get().(*bytes.Buffer) buf.Reset() defer bufPool.Put(buf) // shouldBuf determines whether to execute templates on this response, // since generally we will not want to execute for images or CSS, etc. shouldBuf := func(_ int, header http.Header) bool { ct := header.Get("Content-Type") for _, mt := range []string{ "text/html", "text/plain", "text/markdown", } { if strings.Contains(ct, mt) { return true } } return false } rec := newResponseRecorder(w, buf, shouldBuf) // single-page application file server 😏 // if any other page is being loaded, always give the SPA which will then load the actual page if !strings.HasPrefix(r.URL.Path, "/pages/") && !strings.HasPrefix(r.URL.Path, "/resources/") { r.URL.Path = "/" } s.staticFiles.ServeHTTP(rec, r) if !rec.Buffered() { return nil } if err := executeTemplate(rec, r, s.app); err != nil { return err } rec.Header().Set("Content-Length", strconv.Itoa(buf.Len())) rec.Header().Del("Accept-Ranges") // we don't know ranges for dynamically-created content rec.Header().Del("Last-Modified") // useless for dynamic content since it's always changing // we don't know a way to quickly generate etag for dynamic content, // and weak etags still cause browsers to rely on it even after a // refresh, so disable them until we find a better way to do this rec.Header().Del("Etag") return rec.WriteResponse() } // handleRepoResource serves resources to the UI in this URL format: // / repo / {repoID} / data|assets|thumbnail|image|transcode|motion-photo|dl / {dataFilePath_or_thumbnailItemID.jpg_or_itemID} func (s server) handleRepoResource(w http.ResponseWriter, r *http.Request) error { const minParts, maxParts = 5, 6 parts := strings.SplitN(r.URL.Path, "/", maxParts) if len(parts) < minParts { return Error{ Err: errors.New("insufficient path"), HTTPStatus: http.StatusBadRequest, Log: "parsing repo resource from URI path", Message: "Invalid resource path.", } } repoID := parts[2] resourceType := parts[3] tl, err := getOpenTimeline(repoID) if err != nil { return Error{ Err: err, HTTPStatus: http.StatusBadRequest, Log: "getting open timeline by ID", Message: "Unable to get open timeline with ID in URI.", } } switch resourceType { case timeline.DataFolderName: // data file; serve statically from timeline return s.serveDataFile(w, r, tl, strings.Join(parts[3:], "/")) case timeline.AssetsFolderName: // asset file (such as entity profile picture); serve statically from timeline, // unless obfuscation mode is enabled, then we need to blur the image _, obfuscate := s.app.ObfuscationMode(tl.Timeline) if obfuscate { mimeType := mime.TypeByExtension(path.Ext(parts[len(parts)-1])) if strings.HasPrefix(mimeType, "image/") { assetImagePath := strings.Join(parts[3:], "/") img, err := tl.AssetImage(r.Context(), assetImagePath, true) if err != nil { return Error{ Err: err, HTTPStatus: http.StatusInternalServerError, Log: "loading obfuscated asset image", Message: "Unable to load obfuscated asset image.", } } w.Header().Set("Content-Type", "image/jpeg") _, _ = w.Write(img) } } tl.fileServer.ServeHTTP(w, r) return nil case "thumbnail": // item thumbnail; serve from cache or generate if needed return s.serveThumbnail(w, r, tl) case "image": // preview image; generate on-the-fly return s.servePreviewImage(w, r, tl, parts) case "transcode": // stream video data file in a format that can be played by the browser dataFile := strings.Join(parts[4:], "/") inputPath := tl.FullPath(dataFile) _, obfuscate := s.app.ObfuscationMode(tl.Timeline) return s.transcodeVideo(r.Context(), w, inputPath, nil, obfuscate) case "motion-photo": // given the data path of a photo, stream its motion photo in a format playable by the browser return s.motionPhoto(w, r, tl, parts) case "dl": // download the item as a file return s.downloadItem(w, r, tl, parts) default: return Error{ Err: errors.New("URI must be accessing data, asset such as profile picture, thumbnail, preview image, transcode, motion-photo, or dl"), HTTPStatus: http.StatusBadRequest, Log: "determining requested resource", Message: "Invalid URI; unable to determine requested resource.", } } } func (s server) serveDataFile(w http.ResponseWriter, r *http.Request, tl openedTimeline, dataFile string) error { // but first, help out the MIME sniffer since we often know the correct data type // and are a little smarter than Go's built-in logic results, err := tl.Search(r.Context(), timeline.ItemSearchParams{ DataFile: []string{dataFile}, Astructured: true, Limit: 1, }) if err != nil { return err } if len(results.Items) < 1 { return Error{ Err: fmt.Errorf("no item found with data file: %s", dataFile), HTTPStatus: http.StatusNotFound, Log: "finding item in database", Message: "Data file not found in database.", } } if results.Items[0].DataType != nil { _, obfuscate := s.app.ObfuscationMode(tl.Timeline) if obfuscate && strings.HasPrefix(*results.Items[0].DataType, "video/") { return s.transcodeVideo(r.Context(), w, tl.FullPath(dataFile), nil, obfuscate) } w.Header().Set("Content-Type", *results.Items[0].DataType) } tl.fileServer.ServeHTTP(w, r) return nil } func (s server) servePreviewImage(w http.ResponseWriter, r *http.Request, tl openedTimeline, parts []string) error { filename := parts[4] ext := path.Ext(filename) if !strings.EqualFold(ext, ".jpg") && !strings.EqualFold(ext, ".jpe") && !strings.EqualFold(ext, ".jpeg") && !strings.EqualFold(ext, ".png") && !strings.EqualFold(ext, ".avif") && !strings.EqualFold(ext, ".webp") { return fmt.Errorf("unsupported file extension: %s - only JPEG, PNG, AVIF, and WEBP formats are supported", ext) } itemIDStr := strings.TrimSuffix(filename, ext) itemID, err := strconv.ParseInt(itemIDStr, 10, 64) if err != nil || itemID < 0 { return fmt.Errorf("invalid item ID: %s", itemIDStr) } results, err := tl.Search(r.Context(), timeline.ItemSearchParams{ Repo: tl.ID().String(), RowID: []int64{itemID}, }) if err != nil { return err } if len(results.Items) == 0 { return fmt.Errorf("no item found with ID %d", itemID) } if len(results.Items) > 1 { return fmt.Errorf("somehow, %d items were found having ID %d", len(results.Items), itemID) } itemRow := results.Items[0].ItemRow if itemRow.DataFile == nil { return fmt.Errorf("item %d does not have a data file recorded, so no preview image is possible", itemID) } if itemRow.DataType != nil { if *itemRow.DataType == "image/x-icon" { // icons won't get obfuscated, but last time I checked we had trouble decoding these r.URL.Path = "/" + path.Join("repo", tl.ID().String(), *itemRow.DataFile) return s.serveDataFile(w, r, tl, *itemRow.DataFile) } if !strings.HasPrefix(*itemRow.DataType, "image/") && *itemRow.DataType != "application/pdf" { return fmt.Errorf("media type of item %d does not support preview image: %s", itemID, *itemRow.DataType) } } hash := hex.EncodeToString(itemRow.DataHash) if strings.Trim(r.Header.Get("If-None-Match"), "\"") == hash { w.WriteHeader(http.StatusNotModified) return nil } _, obfuscate := s.app.ObfuscationMode(tl.Timeline) imageBytes, err := tl.GeneratePreviewImage(r.Context(), itemRow, ext, obfuscate) if err != nil { return err } w.Header().Set("Etag", `"`+hash+`"`) modTime := itemRow.Stored if itemRow.Modified != nil { modTime = *itemRow.Modified } http.ServeContent(w, r, filename, modTime, bytes.NewReader(imageBytes)) return nil } func (s server) serveThumbnail(w http.ResponseWriter, r *http.Request, tl openedTimeline) error { var itemDataID int64 itemDataIDStr := r.FormValue("data_id") if itemDataIDStr != "" { var err error itemDataID, err = strconv.ParseInt(itemDataIDStr, 10, 64) if err != nil || itemDataID < 0 { return fmt.Errorf("invalid item data ID: %s", itemDataIDStr) } } // determine thumbnail type; we have to be smart about this because sweet, special // Safari sends "video/*" for img tags (granted, at a lower q-factor/weight, but still...) accept, err := parseAccept(r.Header.Get("Accept")) if err != nil { return Error{ Err: err, HTTPStatus: http.StatusBadRequest, Log: "parsing Accept header", Message: "invalid syntax for Accept header", } } dataType := r.FormValue("data_type") // figure out what type of thumbnail to serve, taking into account // both server and client preferences; by default (if client has no // preference), server prefers an image thumbnail if it's an image, // or a video thumbnail if it's a video; but we can also honor // client preference (for example, videos can have animated image // thumbnails) ourPref := []string{"image/*", "video/*"} if strings.HasPrefix(dataType, "video/") { ourPref[0], ourPref[1] = ourPref[1], ourPref[0] } thumbType := timeline.ImageAVIF if clientPref := accept.preference(ourPref...); clientPref == "video/*" { thumbType = timeline.VideoWebM } // sigh, precious Safari and its