/*
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