/* Copyright 2014 The Camlistore Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // The publisher application serves and renders items published by Camlistore. // That is, items that are children, through a (direct or not) camliPath relation, // of a camliRoot node (a permanode with a camliRoot attribute set). package main import ( "bytes" "encoding/json" "flag" "fmt" "html" "html/template" "io" "io/ioutil" "log" "net/http" "net/url" "os" "path/filepath" "regexp" "runtime" "strconv" "strings" "sync" "camlistore.org/pkg/app" "camlistore.org/pkg/blob" "camlistore.org/pkg/blobserver" "camlistore.org/pkg/blobserver/localdisk" "camlistore.org/pkg/buildinfo" "camlistore.org/pkg/constants" "camlistore.org/pkg/fileembed" "camlistore.org/pkg/httputil" "camlistore.org/pkg/publish" "camlistore.org/pkg/search" "camlistore.org/pkg/server" "camlistore.org/pkg/sorted" "camlistore.org/pkg/syncutil" "camlistore.org/pkg/types/camtypes" "camlistore.org/pkg/webserver" _ "camlistore.org/pkg/sorted/kvfile" ) var ( flagVersion = flag.Bool("version", false, "show version") ) var ( logger = log.New(os.Stderr, "PUBLISHER: ", log.LstdFlags) logf = logger.Printf ) // config is used to unmarshal the application configuration JSON // that we get from Camlistore when we request it at $CAMLI_APP_CONFIG_URL. type config struct { HTTPSCert string `json:"httpsCert,omitempty"` // Path to the HTTPS certificate file. HTTPSKey string `json:"httpsKey,omitempty"` // Path to the HTTPS key file. RootName string `json:"camliRoot"` // Publish root name (i.e. value of the camliRoot attribute on the root permanode). MaxResizeBytes int64 `json:"maxResizeBytes,omitempty"` // See constants.DefaultMaxResizeMem SourceRoot string `json:"sourceRoot,omitempty"` // Path to the app's resources dir, such as html and css files. GoTemplate string `json:"goTemplate"` // Go html template to render the publication. CacheRoot string `json:"cacheRoot,omitempty"` // Root path for the caching blobserver. No caching if empty. } func appConfig() *config { configURL := os.Getenv("CAMLI_APP_CONFIG_URL") if configURL == "" { logger.Fatalf("Publisher application needs a CAMLI_APP_CONFIG_URL env var") } cl, err := app.Client() if err != nil { logger.Fatalf("could not get a client to fetch extra config: %v", err) } conf := &config{} if err := cl.GetJSON(configURL, conf); err != nil { logger.Fatalf("could not get app config at %v: %v", configURL, err) } return conf } func main() { flag.Parse() if *flagVersion { fmt.Fprintf(os.Stderr, "publisher version: %s\nGo version: %s (%s/%s)\n", buildinfo.Version(), runtime.Version(), runtime.GOOS, runtime.GOARCH) return } logf("Starting publisher version %s; Go %s (%s/%s)", buildinfo.Version(), runtime.Version(), runtime.GOOS, runtime.GOARCH) listenAddr, err := app.ListenAddress() if err != nil { logger.Fatalf("Listen address: %v", err) } conf := appConfig() ph := newPublishHandler(conf) if err := ph.initRootNode(); err != nil { logf("%v", err) } ws := webserver.New() ws.Logger = logger ws.Handle("/", ph) if conf.HTTPSCert != "" && conf.HTTPSKey != "" { ws.SetTLS(conf.HTTPSCert, conf.HTTPSKey) } if err := ws.Listen(listenAddr); err != nil { logger.Fatalf("Listen: %v", err) } ws.Serve() } func newPublishHandler(conf *config) *publishHandler { cl, err := app.Client() if err != nil { logger.Fatalf("could not get a client for the publish handler %v", err) } if conf.RootName == "" { logger.Fatal("camliRoot not found in the app configuration") } maxResizeBytes := conf.MaxResizeBytes if maxResizeBytes == 0 { maxResizeBytes = constants.DefaultMaxResizeMem } var CSSFiles []string if conf.SourceRoot != "" { appRoot := filepath.Join(conf.SourceRoot, "app", "publisher") Files = &fileembed.Files{ DirFallback: appRoot, } // TODO(mpl): Can I readdir by listing with "/" on Files, even with DirFallBack? // Apparently not, but retry later. dir, err := os.Open(appRoot) if err != nil { logger.Fatal(err) } defer dir.Close() names, err := dir.Readdirnames(-1) if err != nil { logger.Fatal(err) } for _, v := range names { if strings.HasSuffix(v, ".css") { CSSFiles = append(CSSFiles, v) } } } else { Files.Listable = true dir, err := Files.Open("/") if err != nil { logger.Fatal(err) } defer dir.Close() fis, err := dir.Readdir(-1) if err != nil { logger.Fatal(err) } for _, v := range fis { name := v.Name() if strings.HasSuffix(name, ".css") { CSSFiles = append(CSSFiles, name) } } } // TODO(mpl): add all htmls found in Files to the template if none specified? if conf.GoTemplate == "" { logger.Fatal("a go template is required in the app configuration") } goTemplate, err := goTemplate(Files, conf.GoTemplate) if err != nil { logger.Fatal(err) } serverURL := os.Getenv("CAMLI_API_HOST") if serverURL == "" { logger.Fatal("CAMLI_API_HOST var not set") } var cache blobserver.Storage var thumbMeta *server.ThumbMeta if conf.CacheRoot != "" { cache, err = localdisk.New(conf.CacheRoot) if err != nil { logger.Fatalf("Could not create localdisk cache: %v", err) } thumbsCacheDir := filepath.Join(os.TempDir(), "camli-publisher-cache") if err := os.MkdirAll(thumbsCacheDir, 0700); err != nil { logger.Fatalf("Could not create cache dir %s for %v publisher: %v", thumbsCacheDir, conf.RootName, err) } kv, err := sorted.NewKeyValue(map[string]interface{}{ "type": "kv", "file": filepath.Join(thumbsCacheDir, conf.RootName+"-thumbnails.kv"), }) if err != nil { logger.Fatalf("Could not create kv for %v's thumbs cache: %v", conf.RootName, err) } thumbMeta = server.NewThumbMeta(kv) } return &publishHandler{ rootName: conf.RootName, cl: cl, resizeSem: syncutil.NewSem(maxResizeBytes), staticFiles: Files, goTemplate: goTemplate, CSSFiles: CSSFiles, describedCache: make(map[string]*search.DescribedBlob), cache: cache, thumbMeta: thumbMeta, } } func goTemplate(files *fileembed.Files, templateFile string) (*template.Template, error) { f, err := files.Open(templateFile) if err != nil { return nil, fmt.Errorf("Could not open template %v: %v", templateFile, err) } defer f.Close() templateBytes, err := ioutil.ReadAll(f) if err != nil { return nil, fmt.Errorf("Could not read template %v: %v", templateFile, err) } return template.Must(template.New("subject").Parse(string(templateBytes))), nil } // We're using this interface in a publishHandler, instead of directly // a *client.Client, so we can use a fake client in tests. type client interface { search.QueryDescriber GetJSON(url string, data interface{}) error Post(url string, bodyType string, body io.Reader) error blob.Fetcher } type publishHandler struct { rootName string // Publish root name (i.e. value of the camliRoot attribute on the root permanode). rootNodeMu sync.Mutex rootNode blob.Ref // Root permanode, origin of all camliPaths for this publish handler. cl client // Used for searching, and remote storage. staticFiles *fileembed.Files // For static resources. goTemplate *template.Template // For publishing/rendering. CSSFiles []string resizeSem *syncutil.Sem // Limit peak RAM used by concurrent image thumbnail calls. describedCacheMu sync.RWMutex describedCache map[string]*search.DescribedBlob // So that each item in a gallery does not actually require a describe round-trip. cache blobserver.Storage // For caching images and files, or nil. thumbMeta *server.ThumbMeta // For keeping track of cached images, or nil. } func (ph *publishHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ph.rootNodeMu.Lock() if !ph.rootNode.Valid() { // we want to retry doing this every time because the rootNode could have been created // (by e.g. the owner) since last time. err := ph.initRootNode() if err != nil { httputil.ServeError(w, r, fmt.Errorf("No publish root node: %v", err)) ph.rootNodeMu.Unlock() return } } ph.rootNodeMu.Unlock() preq, err := ph.NewRequest(w, r) if err != nil { httputil.ServeError(w, r, fmt.Errorf("Could not create publish request: %v", err)) return } preq.serveHTTP() } func (ph *publishHandler) initRootNode() error { var getRootNode = func() (blob.Ref, error) { result, err := ph.camliRootQuery() if err != nil { return blob.Ref{}, fmt.Errorf("could not find permanode for root %q of publish handler: %v", ph.rootName, err) } if len(result.Blobs) == 0 || !result.Blobs[0].Blob.Valid() { return blob.Ref{}, fmt.Errorf("could not find permanode for root %q of publish handler: %v", ph.rootName, os.ErrNotExist) } return result.Blobs[0].Blob, nil } node, err := getRootNode() if err != nil { return err } ph.rootNode = node return nil } func (ph *publishHandler) camliRootQuery() (*search.SearchResult, error) { // TODO(mpl): I've voluntarily omitted the owner because it's not clear to // me that we actually care about that. Same for signer in lookupPathTarget. return ph.cl.Query(&search.SearchQuery{ Limit: 1, Constraint: &search.Constraint{ Permanode: &search.PermanodeConstraint{ Attr: "camliRoot", Value: ph.rootName, }, }, }) } func (ph *publishHandler) lookupPathTarget(root blob.Ref, suffix string) (blob.Ref, error) { if suffix == "" { return root, nil } // TODO: verify it's optimized: http://camlistore.org/issue/405 result, err := ph.cl.Query(&search.SearchQuery{ Limit: 1, Constraint: &search.Constraint{ Permanode: &search.PermanodeConstraint{ SkipHidden: true, Relation: &search.RelationConstraint{ Relation: "parent", EdgeType: "camliPath:" + suffix, Any: &search.Constraint{ BlobRefPrefix: root.String(), }, }, }, }, }) if err != nil { return blob.Ref{}, err } if len(result.Blobs) == 0 || !result.Blobs[0].Blob.Valid() { return blob.Ref{}, os.ErrNotExist } return result.Blobs[0].Blob, nil } // Given a blobref and a few hex characters of the digest of the next hop, return the complete // blobref of the prefix, if that's a valid next hop. func (ph *publishHandler) resolvePrefixHop(parent blob.Ref, prefix string) (child blob.Ref, err error) { // TODO: this is a linear scan right now. this should be // optimized to use a new database table of members so this is // a quick lookup. in the meantime it should be in memcached // at least. if len(prefix) < 8 { return blob.Ref{}, fmt.Errorf("Member prefix %q too small", prefix) } des, err := ph.describe(parent) if err != nil { return blob.Ref{}, fmt.Errorf("Failed to describe member %q in parent %q", prefix, parent) } if des.Permanode != nil { cr, ok := des.ContentRef() if ok && strings.HasPrefix(cr.Digest(), prefix) { return cr, nil } for _, member := range des.Members() { if strings.HasPrefix(member.BlobRef.Digest(), prefix) { return member.BlobRef, nil } } crdes, err := ph.describe(cr) if err != nil { return blob.Ref{}, fmt.Errorf("Failed to describe content %q of parent %q", cr, parent) } if crdes.Dir != nil { return ph.resolvePrefixHop(cr, prefix) } } else if des.Dir != nil { for _, child := range des.DirChildren { if strings.HasPrefix(child.Digest(), prefix) { return child, nil } } } return blob.Ref{}, fmt.Errorf("Member prefix %q not found in %q", prefix, parent) } func (ph *publishHandler) describe(br blob.Ref) (*search.DescribedBlob, error) { ph.describedCacheMu.RLock() if des, ok := ph.describedCache[br.String()]; ok { ph.describedCacheMu.RUnlock() return des, nil } ph.describedCacheMu.RUnlock() res, err := ph.cl.Describe(&search.DescribeRequest{ BlobRef: br, Depth: 1, }) if err != nil { return nil, fmt.Errorf("Could not describe %v: %v", br, err) } return res.Meta[br.String()], nil } func (ph *publishHandler) deepDescribe(br blob.Ref) (*search.DescribeResponse, error) { res, err := ph.cl.Query(&search.SearchQuery{ Constraint: &search.Constraint{ BlobRefPrefix: br.String(), CamliType: "permanode", }, Describe: &search.DescribeRequest{ ThumbnailSize: 1000, Depth: 1, Rules: []*search.DescribeRule{ { Attrs: []string{"camliContent", "camliContentImage", "camliMember", "camliPath:*"}, }, }, }, Limit: -1, }) if err != nil { return nil, fmt.Errorf("Could not deep describe %v: %v", br, err) } if res == nil || res.Describe == nil { return nil, fmt.Errorf("no describe result for %v", br) } return res.Describe, nil } // publishRequest is the state around a single HTTP request to the // publish handler type publishRequest struct { ph *publishHandler rw http.ResponseWriter req *http.Request base, suffix, subres string rootpn blob.Ref subject blob.Ref inSubjectChain map[string]bool // blobref -> true subjectBasePath string } func (ph *publishHandler) NewRequest(rw http.ResponseWriter, req *http.Request) (*publishRequest, error) { // splits a path request into its suffix and subresource parts. // e.g. /blog/foo/camli/res/file/xxx -> ("foo", "file/xxx") suffix, res := httputil.PathSuffix(req), "" if strings.HasPrefix(suffix, "-/") { suffix, res = "", suffix[2:] } else if s := strings.SplitN(suffix, "/-/", 2); len(s) == 2 { suffix, res = s[0], s[1] } return &publishRequest{ ph: ph, rw: rw, req: req, suffix: suffix, base: httputil.PathBase(req), subres: res, rootpn: ph.rootNode, inSubjectChain: make(map[string]bool), subjectBasePath: "", }, nil } func (pr *publishRequest) serveHTTP() { if !pr.rootpn.Valid() { pr.rw.WriteHeader(404) return } if pr.Debug() { pr.rw.Header().Set("Content-Type", "text/html") pr.pf("I am publish handler at base %q, serving root %q (permanode=%s), suffix %q, subreq %q
Subject: %s
", pr.subject, pr.subject) return } switch pr.subresourceType() { case "": pr.serveSubjectTemplate() case "b": // TODO: download a raw blob case "f": // file download pr.serveSubresFileDownload() case "i": // image, scaled pr.serveSubresImage() case "s": // static pr.req.URL.Path = pr.subres[len("/=s"):] if len(pr.req.URL.Path) <= 1 { http.Error(pr.rw, "Illegal URL.", http.StatusNotFound) return } file := pr.req.URL.Path[1:] server.ServeStaticFile(pr.rw, pr.req, pr.ph.staticFiles, file) case "z": pr.serveZip() default: pr.rw.WriteHeader(400) pr.pf("Invalid or unsupported resource request.
") } } func (pr *publishRequest) Debug() bool { return pr.req.FormValue("debug") == "1" } var memberRE = regexp.MustCompile(`^/?h([0-9a-f]+)`) func (pr *publishRequest) findSubject() error { if strings.HasPrefix(pr.suffix, "=s/") { pr.subres = "/" + pr.suffix return nil } subject, err := pr.ph.lookupPathTarget(pr.rootpn, pr.suffix) if err != nil { return err } if strings.HasPrefix(pr.subres, "=z/") { // this happens when we are at the root of the published path, // e.g /base/suffix/-/=z/foo.zip // so we need to reset subres as fullpath so that it is detected // properly when switching on pr.subresourceType() pr.subres = "/" + pr.subres // since we return early, we set the subject because that is // what is going to be used as a root node by the zip handler. pr.subject = subject return nil } pr.inSubjectChain[subject.String()] = true pr.subjectBasePath = pr.base + pr.suffix // Chase /h