Home Download Docs Code Community
     1	/*
     2	Copyright 2011 The Perkeep Authors
     3	
     4	Licensed under the Apache License, Version 2.0 (the "License");
     5	you may not use this file except in compliance with the License.
     6	You may obtain a copy of the License at
     7	
     8	     http://www.apache.org/licenses/LICENSE-2.0
     9	
    10	Unless required by applicable law or agreed to in writing, software
    11	distributed under the License is distributed on an "AS IS" BASIS,
    12	WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13	See the License for the specific language governing permissions and
    14	limitations under the License.
    15	*/
    16	
    17	package server
    18	
    19	import (
    20		"context"
    21		"errors"
    22		"fmt"
    23		"io"
    24		"io/fs"
    25		"log"
    26		"net/http"
    27		"os"
    28		"path"
    29		"path/filepath"
    30		"regexp"
    31		"strconv"
    32		"strings"
    33		"time"
    34	
    35		closurestatic "perkeep.org/clients/web/embed/closure/lib"
    36		fontawesomestatic "perkeep.org/clients/web/embed/fontawesome"
    37		keepystatic "perkeep.org/clients/web/embed/keepy"
    38		leafletstatic "perkeep.org/clients/web/embed/leaflet"
    39		lessstatic "perkeep.org/clients/web/embed/less"
    40		opensansstatic "perkeep.org/clients/web/embed/opensans"
    41		reactstatic "perkeep.org/clients/web/embed/react"
    42	
    43		"go4.org/jsonconfig"
    44		"go4.org/syncutil"
    45		"perkeep.org/internal/closure"
    46		"perkeep.org/internal/httputil"
    47		"perkeep.org/internal/osutil"
    48		"perkeep.org/pkg/blob"
    49		"perkeep.org/pkg/blobserver"
    50		"perkeep.org/pkg/cacher"
    51		"perkeep.org/pkg/constants"
    52		"perkeep.org/pkg/search"
    53		"perkeep.org/pkg/server/app"
    54		"perkeep.org/pkg/sorted"
    55		"perkeep.org/pkg/types/camtypes"
    56		uistatic "perkeep.org/server/perkeepd/ui"
    57		"rsc.io/qr"
    58	)
    59	
    60	var (
    61		staticFilePattern  = regexp.MustCompile(`^([a-zA-Z0-9\-\_\.]+\.(html|js|css|png|jpg|gif|svg))$`)
    62		identOrDotPattern  = regexp.MustCompile(`^[a-zA-Z\_]+(\.[a-zA-Z\_]+)*$`)
    63		thumbnailPattern   = regexp.MustCompile(`^thumbnail/([^/]+)(/.*)?$`)
    64		treePattern        = regexp.MustCompile(`^tree/([^/]+)(/.*)?$`)
    65		closurePattern     = regexp.MustCompile(`^(closure/([^/]+)(/.*)?)$`)
    66		lessPattern        = regexp.MustCompile(`^less/(.+)$`)
    67		reactPattern       = regexp.MustCompile(`^react/(.+)$`)
    68		leafletPattern     = regexp.MustCompile(`^leaflet/(.+)$`)
    69		fontawesomePattern = regexp.MustCompile(`^fontawesome/(.+)$`)
    70		openSansPattern    = regexp.MustCompile(`^opensans/(([^/]+)(/.*)?)$`)
    71		keepyPattern       = regexp.MustCompile(`^keepy/(.+)$`)
    72	
    73		disableThumbCache, _ = strconv.ParseBool(os.Getenv("CAMLI_DISABLE_THUMB_CACHE"))
    74	
    75		vendorEmbed = filepath.Join("clients", "web", "embed")
    76	)
    77	
    78	// UIHandler handles serving the UI and discovery JSON.
    79	type UIHandler struct {
    80		publishRoots map[string]*publishRoot
    81	
    82		prefix        string // of the UI handler itself
    83		root          *RootHandler
    84		search        *search.Handler
    85		shareImporter *shareImporter // nil if no root.Storage
    86	
    87		// Cache optionally specifies a cache blob server, used for
    88		// caching image thumbnails and other emphemeral data.
    89		Cache blobserver.Storage // or nil
    90	
    91		// Limit peak RAM used by concurrent image thumbnail calls.
    92		resizeSem *syncutil.Sem
    93		thumbMeta *ThumbMeta // optional thumbnail key->blob.Ref cache
    94	
    95		// sourceRoot optionally specifies the path to root of Perkeep's
    96		// source. If empty, the UI files must be compiled in to the
    97		// binary (with go run make.go).  This comes from the "sourceRoot"
    98		// ui handler config option.
    99		sourceRoot string
   100	
   101		uiDir string // if sourceRoot != "", this is sourceRoot+"/server/perkeepd/ui"
   102	
   103		closureHandler         http.Handler
   104		fileLessHandler        http.Handler
   105		fileReactHandler       http.Handler
   106		fileLeafletHandler     http.Handler
   107		fileFontawesomeHandler http.Handler
   108		fileOpenSansHandler    http.Handler
   109		fileKeepyHandler       http.Handler
   110	
   111		// Embed Filesystems.
   112		// Some of them may point to the disk.
   113		uiFiles                fs.FS
   114		serverFiles            fs.FS
   115		lessStaticFiles        fs.FS
   116		reactStaticFiles       fs.FS
   117		leafletStaticFiles     fs.FS
   118		keepyStaticFiles       fs.FS
   119		fontawesomeStaticFiles fs.FS
   120		opensansStaticFiles    fs.FS
   121	}
   122	
   123	func init() {
   124		blobserver.RegisterHandlerConstructor("ui", uiFromConfig)
   125	}
   126	
   127	// newKVOrNil wraps sorted.NewKeyValue and adds the ability
   128	// to pass a nil conf to get a (nil, nil) response.
   129	func newKVOrNil(conf jsonconfig.Obj) (sorted.KeyValue, error) {
   130		if len(conf) == 0 {
   131			return nil, nil
   132		}
   133		return sorted.NewKeyValueMaybeWipe(conf)
   134	}
   135	
   136	func uiFromConfig(ld blobserver.Loader, conf jsonconfig.Obj) (h http.Handler, err error) {
   137		ui := &UIHandler{
   138			prefix:     ld.MyPrefix(),
   139			sourceRoot: conf.OptionalString("sourceRoot", ""),
   140			resizeSem: syncutil.NewSem(int64(conf.OptionalInt("maxResizeBytes",
   141				constants.DefaultMaxResizeMem))),
   142	
   143			serverFiles:            Files,
   144			uiFiles:                uistatic.Files,
   145			lessStaticFiles:        lessstatic.Files,
   146			reactStaticFiles:       reactstatic.Files,
   147			leafletStaticFiles:     leafletstatic.Files,
   148			keepyStaticFiles:       keepystatic.Files,
   149			fontawesomeStaticFiles: fontawesomestatic.Files,
   150			opensansStaticFiles:    opensansstatic.Files,
   151		}
   152		cachePrefix := conf.OptionalString("cache", "")
   153		scaledImageConf := conf.OptionalObject("scaledImage")
   154		if err = conf.Validate(); err != nil {
   155			return
   156		}
   157	
   158		scaledImageKV, err := newKVOrNil(scaledImageConf)
   159		if err != nil {
   160			return nil, fmt.Errorf("in UI handler's scaledImage: %v", err)
   161		}
   162		if scaledImageKV != nil && cachePrefix == "" {
   163			return nil, fmt.Errorf("in UI handler, can't specify scaledImage without cache")
   164		}
   165		if cachePrefix != "" {
   166			bs, err := ld.GetStorage(cachePrefix)
   167			if err != nil {
   168				return nil, fmt.Errorf("UI handler's cache of %q error: %v", cachePrefix, err)
   169			}
   170			ui.Cache = bs
   171			ui.thumbMeta = NewThumbMeta(scaledImageKV)
   172		}
   173	
   174		if ui.sourceRoot == "" {
   175			ui.sourceRoot = os.Getenv("CAMLI_DEV_CAMLI_ROOT")
   176			if ui.sourceRoot == "" {
   177				files, err := uistatic.Files.ReadDir(".")
   178				if err != nil {
   179					return nil, fmt.Errorf("Could not read static files: %v", err)
   180				}
   181				if len(files) == 0 {
   182					ui.sourceRoot, err = osutil.GoPackagePath("perkeep.org")
   183					if err != nil {
   184						log.Printf("Warning: server not compiled with linked-in UI resources (HTML, JS, CSS), and perkeep.org not found in GOPATH.")
   185					} else {
   186						log.Printf("Using UI resources (HTML, JS, CSS) from disk, under %v", ui.sourceRoot)
   187					}
   188				}
   189			}
   190		}
   191		if ui.sourceRoot != "" {
   192			ui.uiDir = filepath.Join(ui.sourceRoot, filepath.FromSlash("server/perkeepd/ui"))
   193			// Ignore any fileembed files:
   194			ui.serverFiles = os.DirFS(filepath.Join(ui.sourceRoot, filepath.FromSlash("pkg/server")))
   195			ui.uiFiles = os.DirFS(ui.uiDir)
   196		}
   197	
   198		ui.closureHandler, err = ui.makeClosureHandler(ui.sourceRoot)
   199		if err != nil {
   200			return nil, fmt.Errorf(`Invalid "sourceRoot" value of %q: %v"`, ui.sourceRoot, err)
   201		}
   202	
   203		if ui.sourceRoot != "" {
   204			ui.fileReactHandler, err = makeFileServer(ui.sourceRoot, filepath.Join(vendorEmbed, "react"), "react-dom.min.js")
   205			if err != nil {
   206				return nil, fmt.Errorf("Could not make react handler: %s", err)
   207			}
   208			ui.fileLeafletHandler, err = makeFileServer(ui.sourceRoot, filepath.Join(vendorEmbed, "leaflet"), "leaflet.js")
   209			if err != nil {
   210				return nil, fmt.Errorf("Could not make leaflet handler: %s", err)
   211			}
   212			ui.fileKeepyHandler, err = makeFileServer(ui.sourceRoot, filepath.Join(vendorEmbed, "keepy"), "keepy-dancing.png")
   213			if err != nil {
   214				return nil, fmt.Errorf("Could not make keepy handler: %s", err)
   215			}
   216			ui.fileFontawesomeHandler, err = makeFileServer(ui.sourceRoot, filepath.Join(vendorEmbed, "fontawesome"), "css/font-awesome.css")
   217			if err != nil {
   218				return nil, fmt.Errorf("Could not make fontawesome handler: %s", err)
   219			}
   220			ui.fileLessHandler, err = makeFileServer(ui.sourceRoot, filepath.Join(vendorEmbed, "less"), "less.js")
   221			if err != nil {
   222				return nil, fmt.Errorf("Could not make less handler: %s", err)
   223			}
   224			ui.fileOpenSansHandler, err = makeFileServer(ui.sourceRoot, filepath.Join(vendorEmbed, "opensans"), "OpenSans.css")
   225			if err != nil {
   226				return nil, fmt.Errorf("Could not make Open Sans handler: %s", err)
   227			}
   228		}
   229	
   230		rootPrefix, _, err := ld.FindHandlerByType("root")
   231		if err != nil {
   232			return nil, errors.New("No root handler configured, which is necessary for the ui handler")
   233		}
   234		if h, err := ld.GetHandler(rootPrefix); err == nil {
   235			ui.root = h.(*RootHandler)
   236			ui.root.registerUIHandler(ui)
   237		} else {
   238			return nil, errors.New("failed to find the 'root' handler")
   239		}
   240	
   241		if ui.root.Storage != nil {
   242			ui.shareImporter = &shareImporter{
   243				dest: ui.root.Storage,
   244			}
   245		}
   246	
   247		return ui, nil
   248	}
   249	
   250	type publishRoot struct {
   251		Name      string
   252		Permanode blob.Ref
   253		Prefix    string
   254	}
   255	
   256	// InitHandler goes through all the other configured handlers to discover
   257	// the publisher ones, and uses them to populate ui.publishRoots.
   258	func (ui *UIHandler) InitHandler(hl blobserver.FindHandlerByTyper) error {
   259		// InitHandler is called after all handlers have been setup, so the bootstrap
   260		// of the camliRoot node for publishers in dev-mode is already done.
   261		searchPrefix, _, err := hl.FindHandlerByType("search")
   262		if err != nil {
   263			return errors.New("No search handler configured, which is necessary for the ui handler")
   264		}
   265		var sh *search.Handler
   266		htype, hi := hl.AllHandlers()
   267		if h, ok := hi[searchPrefix]; !ok {
   268			return errors.New("failed to find the \"search\" handler")
   269		} else {
   270			sh = h.(*search.Handler)
   271			ui.search = sh
   272		}
   273		camliRootQuery := func(camliRoot string) (*search.SearchResult, error) {
   274			return sh.Query(context.TODO(), &search.SearchQuery{
   275				Limit: 1,
   276				Constraint: &search.Constraint{
   277					Permanode: &search.PermanodeConstraint{
   278						Attr:  "camliRoot",
   279						Value: camliRoot,
   280					},
   281				},
   282			})
   283		}
   284		for prefix, typ := range htype {
   285			if typ != "app" {
   286				continue
   287			}
   288			ah, ok := hi[prefix].(*app.Handler)
   289			if !ok {
   290				panic(fmt.Sprintf("UI: handler for %v has type \"app\" but is not app.Handler", prefix))
   291			}
   292			// TODO(mpl): this check is weak, as the user could very well
   293			// use another binary name for the publisher app. We should
   294			// introduce/use another identifier.
   295			if ah.ProgramName() != "publisher" {
   296				continue
   297			}
   298			appConfig := ah.AppConfig()
   299			if appConfig == nil {
   300				log.Printf("UI: app handler for %v has no appConfig", prefix)
   301				continue
   302			}
   303			camliRoot, ok := appConfig["camliRoot"].(string)
   304			if !ok {
   305				log.Printf("UI: camliRoot in appConfig is %T, want string", appConfig["camliRoot"])
   306				continue
   307			}
   308			result, err := camliRootQuery(camliRoot)
   309			if err != nil {
   310				log.Printf("UI: could not find permanode for camliRoot %v: %v", camliRoot, err)
   311				continue
   312			}
   313			if len(result.Blobs) == 0 || !result.Blobs[0].Blob.Valid() {
   314				log.Printf("UI: no valid permanode for camliRoot %v", camliRoot)
   315				continue
   316			}
   317			if ui.publishRoots == nil {
   318				ui.publishRoots = make(map[string]*publishRoot)
   319			}
   320			ui.publishRoots[prefix] = &publishRoot{
   321				Name:      camliRoot,
   322				Prefix:    prefix,
   323				Permanode: result.Blobs[0].Blob,
   324			}
   325		}
   326		return nil
   327	}
   328	
   329	func (ui *UIHandler) makeClosureHandler(root string) (http.Handler, error) {
   330		return makeClosureHandler(root, "ui")
   331	}
   332	
   333	// makeClosureHandler returns a handler to serve Closure files.
   334	// root is either:
   335	//  1. empty: use the Closure files compiled in to the binary (if
   336	//     available), else redirect to the Internet.
   337	//  2. a URL prefix: base of Perkeep to get Closure to redirect to
   338	//  3. a path on disk to the root of camlistore's source (which
   339	//     contains the necessary subset of Closure files)
   340	func makeClosureHandler(root, handlerName string) (http.Handler, error) {
   341		// devcam server environment variable takes precedence:
   342		if d := os.Getenv("CAMLI_DEV_CLOSURE_DIR"); d != "" {
   343			log.Printf("%v: serving Closure from devcam server's $CAMLI_DEV_CLOSURE_DIR: %v", handlerName, d)
   344			return http.FileServer(http.Dir(d)), nil
   345		}
   346		if root == "" {
   347			fs := closurestatic.Closure
   348			log.Printf("%v: serving Closure from embedded resources", handlerName)
   349			return http.FileServer(http.FS(fs)), nil
   350		}
   351		if strings.HasPrefix(root, "http") {
   352			log.Printf("%v: serving Closure using redirects to %v", handlerName, root)
   353			return closureRedirector(root), nil
   354		}
   355	
   356		path := filepath.Join(vendorEmbed, "closure", "lib", "closure")
   357		return makeFileServer(root, path, filepath.Join("goog", "base.js"))
   358	}
   359	
   360	func makeFileServer(sourceRoot string, pathToServe string, expectedContentPath string) (http.Handler, error) {
   361		fi, err := os.Stat(sourceRoot)
   362		if err != nil {
   363			return nil, err
   364		}
   365		if !fi.IsDir() {
   366			return nil, errors.New("not a directory")
   367		}
   368		dirToServe := filepath.Join(sourceRoot, pathToServe)
   369		_, err = os.Stat(filepath.Join(dirToServe, expectedContentPath))
   370		if err != nil {
   371			return nil, fmt.Errorf("directory doesn't contain %s; wrong directory?", expectedContentPath)
   372		}
   373		return http.FileServer(http.Dir(dirToServe)), nil
   374	}
   375	
   376	// closureRedirector is a hack to redirect requests for Closure's million *.js files
   377	// to https://closure-library.googlecode.com/git.
   378	// TODO: this doesn't work when offline. We need to run genjsdeps over all of the Perkeep
   379	// UI to figure out which Closure *.js files to fileembed and generate zembed. Then this
   380	// type can be deleted.
   381	type closureRedirector string
   382	
   383	func (base closureRedirector) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
   384		newURL := string(base) + "/" + path.Clean(httputil.PathSuffix(req))
   385		http.Redirect(rw, req, newURL, http.StatusTemporaryRedirect)
   386	}
   387	
   388	func camliMode(req *http.Request) string {
   389		return req.URL.Query().Get("camli.mode")
   390	}
   391	
   392	func wantsBlobRef(req *http.Request) bool {
   393		_, ok := blob.ParseKnown(httputil.PathSuffix(req))
   394		return ok
   395	}
   396	
   397	func wantsDiscovery(req *http.Request) bool {
   398		return httputil.IsGet(req) &&
   399			(req.Header.Get("Accept") == "text/x-camli-configuration" ||
   400				camliMode(req) == "config")
   401	}
   402	
   403	func wantsUploadHelper(req *http.Request) bool {
   404		return req.Method == "POST" && camliMode(req) == "uploadhelper"
   405	}
   406	
   407	func wantsPermanode(req *http.Request) bool {
   408		return httputil.IsGet(req) && blob.ValidRefString(req.FormValue("p"))
   409	}
   410	
   411	func wantsBlobInfo(req *http.Request) bool {
   412		return httputil.IsGet(req) && blob.ValidRefString(req.FormValue("b"))
   413	}
   414	
   415	func getSuffixMatches(req *http.Request, pattern *regexp.Regexp) bool {
   416		if httputil.IsGet(req) {
   417			suffix := httputil.PathSuffix(req)
   418			return pattern.MatchString(suffix)
   419		}
   420		return false
   421	}
   422	
   423	func (ui *UIHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
   424		suffix := httputil.PathSuffix(req)
   425	
   426		rw.Header().Set("Vary", "Accept")
   427		switch {
   428		case wantsDiscovery(req):
   429			ui.root.serveDiscovery(rw, req)
   430		case wantsUploadHelper(req):
   431			ui.serveUploadHelper(rw, req)
   432		case strings.HasPrefix(suffix, "download/"):
   433			ui.serveDownload(rw, req)
   434		case strings.HasPrefix(suffix, "importshare"):
   435			ui.importShare(rw, req)
   436		case strings.HasPrefix(suffix, "thumbnail/"):
   437			ui.serveThumbnail(rw, req)
   438		case strings.HasPrefix(suffix, "tree/"):
   439			ui.serveFileTree(rw, req)
   440		case strings.HasPrefix(suffix, "qr/"):
   441			ui.serveQR(rw, req)
   442		case getSuffixMatches(req, closurePattern):
   443			ui.serveClosure(rw, req)
   444		case getSuffixMatches(req, lessPattern):
   445			ui.serveFromDiskOrStatic(rw, req, lessPattern, ui.fileLessHandler, ui.lessStaticFiles)
   446		case getSuffixMatches(req, reactPattern):
   447			ui.serveFromDiskOrStatic(rw, req, reactPattern, ui.fileReactHandler, ui.reactStaticFiles)
   448		case getSuffixMatches(req, leafletPattern):
   449			ui.serveFromDiskOrStatic(rw, req, leafletPattern, ui.fileLeafletHandler, ui.leafletStaticFiles)
   450		case getSuffixMatches(req, keepyPattern):
   451			ui.serveFromDiskOrStatic(rw, req, keepyPattern, ui.fileKeepyHandler, ui.keepyStaticFiles)
   452		case getSuffixMatches(req, fontawesomePattern):
   453			ui.serveFromDiskOrStatic(rw, req, fontawesomePattern, ui.fileFontawesomeHandler, ui.fontawesomeStaticFiles)
   454		case getSuffixMatches(req, openSansPattern):
   455			ui.serveFromDiskOrStatic(rw, req, openSansPattern, ui.fileOpenSansHandler, ui.opensansStaticFiles)
   456		default:
   457			file := ""
   458			if m := staticFilePattern.FindStringSubmatch(suffix); m != nil {
   459				file = m[1]
   460			} else {
   461				switch {
   462				case wantsBlobRef(req):
   463					file = "index.html"
   464				case wantsPermanode(req):
   465					file = "permanode.html"
   466				case wantsBlobInfo(req):
   467					file = "blobinfo.html"
   468				case req.URL.Path == httputil.PathBase(req):
   469					file = "index.html"
   470				default:
   471					http.Error(rw, "Illegal URL.", http.StatusNotFound)
   472					return
   473				}
   474			}
   475			if file == "deps.js" {
   476				serveDepsJS(rw, req, ui.uiDir)
   477				return
   478			}
   479			ServeStaticFile(rw, req, ui.uiFiles, file)
   480		}
   481	}
   482	
   483	// ServeStaticFile serves file from the root virtual filesystem.
   484	func ServeStaticFile(rw http.ResponseWriter, req *http.Request, root fs.FS, file string) {
   485		f, err := root.Open(file)
   486		if err != nil {
   487			http.NotFound(rw, req)
   488			log.Printf("Failed to open file %q from embedded resources: %v", file, err)
   489			return
   490		}
   491		defer f.Close()
   492		var modTime time.Time
   493		if fi, err := f.Stat(); err == nil {
   494			modTime = fi.ModTime()
   495		}
   496		http.ServeContent(rw, req, file, modTime, f.(io.ReadSeeker))
   497	}
   498	
   499	func (ui *UIHandler) discovery() *camtypes.UIDiscovery {
   500		pubRoots := map[string]*camtypes.PublishRootDiscovery{}
   501		for _, v := range ui.publishRoots {
   502			rd := &camtypes.PublishRootDiscovery{
   503				Name:             v.Name,
   504				Prefix:           []string{v.Prefix},
   505				CurrentPermanode: v.Permanode,
   506			}
   507			pubRoots[v.Name] = rd
   508		}
   509	
   510		mapClustering, _ := strconv.ParseBool(os.Getenv("CAMLI_DEV_MAP_CLUSTERING"))
   511		uiDisco := &camtypes.UIDiscovery{
   512			UIRoot:          ui.prefix,
   513			UploadHelper:    ui.prefix + "?camli.mode=uploadhelper",
   514			DownloadHelper:  path.Join(ui.prefix, "download") + "/",
   515			DirectoryHelper: path.Join(ui.prefix, "tree") + "/",
   516			PublishRoots:    pubRoots,
   517			MapClustering:   mapClustering,
   518			ImportShare:     path.Join(ui.prefix, "importshare") + "/",
   519		}
   520		return uiDisco
   521	}
   522	
   523	func (ui *UIHandler) serveDownload(w http.ResponseWriter, r *http.Request) {
   524		if ui.root.Storage == nil {
   525			http.Error(w, "No BlobRoot configured", 500)
   526			return
   527		}
   528	
   529		dh := &DownloadHandler{
   530			// TODO(mpl): for more efficiency, the cache itself should be a
   531			// blobpacked, or really anything better optimized for file reading
   532			// than a blobserver.localdisk (which is what ui.Cache most likely is).
   533			Fetcher: cacher.NewCachingFetcher(ui.Cache, ui.root.Storage),
   534			Search:  ui.search,
   535		}
   536		dh.ServeHTTP(w, r)
   537	}
   538	
   539	func (ui *UIHandler) importShare(w http.ResponseWriter, r *http.Request) {
   540		if ui.shareImporter == nil {
   541			http.Error(w, "No ShareImporter capacity", 500)
   542			return
   543		}
   544		ui.shareImporter.ServeHTTP(w, r)
   545	}
   546	
   547	func (ui *UIHandler) serveThumbnail(rw http.ResponseWriter, req *http.Request) {
   548		if ui.root.Storage == nil {
   549			http.Error(rw, "No BlobRoot configured", 500)
   550			return
   551		}
   552	
   553		suffix := httputil.PathSuffix(req)
   554		m := thumbnailPattern.FindStringSubmatch(suffix)
   555		if m == nil {
   556			httputil.ErrorRouting(rw, req)
   557			return
   558		}
   559	
   560		query := req.URL.Query()
   561		width, _ := strconv.Atoi(query.Get("mw"))
   562		height, _ := strconv.Atoi(query.Get("mh"))
   563		blobref, ok := blob.Parse(m[1])
   564		if !ok {
   565			http.Error(rw, "Invalid blobref", http.StatusBadRequest)
   566			return
   567		}
   568	
   569		if width == 0 {
   570			width = search.MaxImageSize
   571		}
   572		if height == 0 {
   573			height = search.MaxImageSize
   574		}
   575	
   576		th := &ImageHandler{
   577			Fetcher:   ui.root.Storage,
   578			Cache:     ui.Cache,
   579			MaxWidth:  width,
   580			MaxHeight: height,
   581			ThumbMeta: ui.thumbMeta,
   582			ResizeSem: ui.resizeSem,
   583			Search:    ui.search,
   584		}
   585		th.ServeHTTP(rw, req, blobref)
   586	}
   587	
   588	func (ui *UIHandler) serveFileTree(rw http.ResponseWriter, req *http.Request) {
   589		if ui.root.Storage == nil {
   590			http.Error(rw, "No BlobRoot configured", 500)
   591			return
   592		}
   593	
   594		suffix := httputil.PathSuffix(req)
   595		m := treePattern.FindStringSubmatch(suffix)
   596		if m == nil {
   597			httputil.ErrorRouting(rw, req)
   598			return
   599		}
   600	
   601		blobref, ok := blob.Parse(m[1])
   602		if !ok {
   603			http.Error(rw, "Invalid blobref", http.StatusBadRequest)
   604			return
   605		}
   606	
   607		fth := &FileTreeHandler{
   608			Fetcher: ui.root.Storage,
   609			file:    blobref,
   610		}
   611		fth.ServeHTTP(rw, req)
   612	}
   613	
   614	func (ui *UIHandler) serveClosure(rw http.ResponseWriter, req *http.Request) {
   615		suffix := httputil.PathSuffix(req)
   616		if ui.closureHandler == nil {
   617			log.Printf("%v not served: closure handler is nil", suffix)
   618			http.NotFound(rw, req)
   619			return
   620		}
   621		m := closurePattern.FindStringSubmatch(suffix)
   622		if m == nil {
   623			httputil.ErrorRouting(rw, req)
   624			return
   625		}
   626		req.URL.Path = "/" + m[1]
   627		ui.closureHandler.ServeHTTP(rw, req)
   628	}
   629	
   630	// serveFromDiskOrStatic matches rx against req's path and serves the match either from disk (if non-nil) or from static (embedded in the binary).
   631	func (ui *UIHandler) serveFromDiskOrStatic(rw http.ResponseWriter, req *http.Request, rx *regexp.Regexp, disk http.Handler, static fs.FS) {
   632		suffix := httputil.PathSuffix(req)
   633		m := rx.FindStringSubmatch(suffix)
   634		if m == nil {
   635			panic("Caller should verify that rx matches")
   636		}
   637		file := m[1]
   638		if disk != nil {
   639			req.URL.Path = "/" + file
   640			disk.ServeHTTP(rw, req)
   641		} else {
   642			ServeStaticFile(rw, req, static, file)
   643		}
   644	
   645	}
   646	
   647	func (ui *UIHandler) serveQR(rw http.ResponseWriter, req *http.Request) {
   648		url := req.URL.Query().Get("url")
   649		if url == "" {
   650			http.Error(rw, "Missing url parameter.", http.StatusBadRequest)
   651			return
   652		}
   653		code, err := qr.Encode(url, qr.L)
   654		if err != nil {
   655			http.Error(rw, err.Error(), http.StatusInternalServerError)
   656			return
   657		}
   658		rw.Header().Set("Content-Type", "image/png")
   659		rw.Write(code.PNG())
   660	}
   661	
   662	// serveDepsJS serves an auto-generated Closure deps.js file.
   663	func serveDepsJS(rw http.ResponseWriter, req *http.Request, dir string) {
   664		var root http.FileSystem
   665		if dir == "" {
   666			root = http.FS(uistatic.Files)
   667		} else {
   668			root = http.Dir(dir)
   669		}
   670	
   671		b, err := closure.GenDeps(root)
   672		if err != nil {
   673			log.Print(err)
   674			http.Error(rw, "Server error", 500)
   675			return
   676		}
   677		rw.Header().Set("Content-Type", "text/javascript; charset=utf-8")
   678		rw.Write([]byte("// auto-generated from perkeepd\n"))
   679		rw.Write(b)
   680	}
Website layout inspired by memcached.
Content by the authors.