diff --git a/config/.gitignore b/config/.gitignore new file mode 100644 index 000000000..139232f09 --- /dev/null +++ b/config/.gitignore @@ -0,0 +1 @@ +flickr-credentials.json diff --git a/config/dev-server-config.json b/config/dev-server-config.json index dd80bbaa0..9b8474832 100644 --- a/config/dev-server-config.json +++ b/config/dev-server-config.json @@ -24,6 +24,7 @@ "blobRoot": "/bs-and-maybe-also-index/", "searchRoot": "/my-search/", "cache": "/cache/", + "goTemplate": "blog.html", "devBootstrapPermanodeUsing": "/sighelper/" } }, @@ -38,6 +39,7 @@ "scaledImage": "lrucache", "css": ["pics.css"], "js": ["pics.js"], + "goTemplate": "gallery.html", "devBootstrapPermanodeUsing": "/sighelper/" } }, @@ -269,6 +271,14 @@ } }, + "/importer-flickr/": { + "handler": "importer-flickr", + "enabled": ["_env", "${CAMLI_FLICKR_ENABLED}", false], + "handlerArgs": { + "apiKey": ["_env", "${CAMLI_FLICKR_API_KEY}", ""] + } + }, + "/share/": { "handler": "share", "handlerArgs": { diff --git a/dev/devcam/server.go b/dev/devcam/server.go index 436c9715b..a15c5ae89 100644 --- a/dev/devcam/server.go +++ b/dev/devcam/server.go @@ -61,7 +61,8 @@ type serverCmd struct { fullClosure bool - openBrowser bool + openBrowser bool + flickrAPIKey string // end of flag vars listen string // address + port to listen on @@ -94,6 +95,7 @@ func init() { flags.BoolVar(&cmd.fullClosure, "fullclosure", false, "Use the ondisk closure library.") flags.BoolVar(&cmd.openBrowser, "openbrowser", false, "Open the start page on startup.") + flags.StringVar(&cmd.flickrAPIKey, "flickrapikey", "", "The key and secret to use with the Flickr importer. Formatted as ':'.") return cmd }) } @@ -143,6 +145,7 @@ func (c *serverCmd) setCamliRoot() error { if err := os.RemoveAll(c.camliRoot); err != nil { return fmt.Errorf("Could not wipe %v: %v", c.camliRoot, err) } + os.Remove(filepath.Join("config", "flickr-credentials.json")) } return nil } @@ -249,6 +252,11 @@ func (c *serverCmd) setEnvVars() error { setenv("CAMLI_SECRET_RING", filepath.Join(camliSrcRoot, filepath.FromSlash(defaultSecring))) setenv("CAMLI_KEYID", defaultKeyID) + if c.flickrAPIKey != "" { + setenv("CAMLI_FLICKR_ENABLED", "true") + setenv("CAMLI_FLICKR_API_KEY", c.flickrAPIKey) + } + setenv("CAMLI_CONFIG_DIR", "config") return nil } diff --git a/doc/publishing/README b/doc/publishing/README new file mode 100644 index 000000000..2d11b0b86 --- /dev/null +++ b/doc/publishing/README @@ -0,0 +1,17 @@ +Camlistore uses Go html templates (http://golang.org/pkg/text/template/) to publish pages. + +Resources for publishing, such as go templates, javascript and css files should be placed in server/camlistored/ui/, so they can be served directly when using the dev server or automatically embedded when using camlistored directly. + +You can then specify those resources through the configuration file. For example, there already is a go template (gallery.html), javascript file (pics.js) and css file (pics.css) that work together to provide publishing for image galleries. The dev server config (config/dev-server-config.json) already uses them. Here is how one would use them in the server config ($HOME/.config/camlistore/server-config.json): + +"publish": { + "/pics/": { + "rootPermanode": "sha1-6cbe9e1c35e854eab028cba43d099d35ceae0de8", + "style": "pics.css", + "js": "pics.js", + "goTemplate": "gallery.html" + } +} + +If you want to provide your own (Go) template, see http://camlistore.org/pkg/publish for the data structures and functions available to the template. + diff --git a/pkg/importer/flickr/auth.go b/pkg/importer/flickr/auth.go index e783c2fc7..754cca55f 100644 --- a/pkg/importer/flickr/auth.go +++ b/pkg/importer/flickr/auth.go @@ -61,8 +61,12 @@ func writeCredentials(user *userInfo) { } } +// This returns nil,nil if the file doesn't exist. Any other error bad. func readCredentials() (*userInfo, error) { fi, err := os.Open(userFile) + if os.IsNotExist(err) { + return nil, nil + } if err != nil { return nil, err } diff --git a/pkg/importer/flickr/flickr.go b/pkg/importer/flickr/flickr.go index b7755cd62..861f817f2 100644 --- a/pkg/importer/flickr/flickr.go +++ b/pkg/importer/flickr/flickr.go @@ -24,6 +24,7 @@ import ( "log" "net/http" "net/url" + "strings" "camlistore.org/pkg/importer" "camlistore.org/pkg/jsonconfig" @@ -45,14 +46,22 @@ type imp struct { } func newFromConfig(cfg jsonconfig.Obj, host *importer.Host) (importer.Importer, error) { - oauthClient.Credentials = oauth.Credentials{ - Token: cfg.OptionalString("appKey", ""), - Secret: cfg.OptionalString("appSecret", ""), - } + apiKey := cfg.RequiredString("apiKey") if err := cfg.Validate(); err != nil { return nil, err } - user, _ := readCredentials() + parts := strings.Split(apiKey, ":") + if len(parts) != 2 { + return nil, fmt.Errorf("Flickr importer: Invalid apiKey configuration: %q", apiKey) + } + oauthClient.Credentials = oauth.Credentials{ + Token: parts[0], + Secret: parts[1], + } + user, err := readCredentials() + if err != nil { + return nil, err + } return &imp{ host: host, user: user, diff --git a/pkg/index/indextest/tests.go b/pkg/index/indextest/tests.go index 7883414a0..17dca1fee 100644 --- a/pkg/index/indextest/tests.go +++ b/pkg/index/indextest/tests.go @@ -74,7 +74,7 @@ func (id *IndexDeps) Set(key, value string) error { return id.Index.Storage().Set(key, value) } -func (id *IndexDeps) dumpIndex(t *testing.T) { +func (id *IndexDeps) DumpIndex(t *testing.T) { t.Logf("Begin index dump:") it := id.Index.Storage().Find("") for it.Next() { @@ -341,7 +341,7 @@ func Index(t *testing.T, initIdx func() *index.Index) { ) lastPermanodeMutation := id.lastTime() - id.dumpIndex(t) + id.DumpIndex(t) key := "signerkeyid:sha1-ad87ca5c78bd0ce1195c46f7c98e6025abbaf007" if g, e := id.Get(key), "2931A67C26F5ABDA"; g != e { @@ -610,7 +610,7 @@ func PathsOfSignerTarget(t *testing.T, initIdx func() *index.Index) { claim2 := id.SetAttribute(pn, "camliPath:with|pipe", "targ-124") t.Logf("made path claims %q and %q", claim1, claim2) - id.dumpIndex(t) + id.DumpIndex(t) type test struct { blobref string @@ -657,7 +657,7 @@ func Files(t *testing.T, initIdx func() *index.Index) { fileTime := time.Unix(1361250375, 0) fileRef, wholeRef := id.UploadFile("foo.html", "I am an html file.", fileTime) t.Logf("uploaded fileref %q, wholeRef %q", fileRef, wholeRef) - id.dumpIndex(t) + id.DumpIndex(t) // ExistingFileSchemas { @@ -714,7 +714,7 @@ func EdgesTo(t *testing.T, initIdx func() *index.Index) { t.Logf("edge %s --> %s", pn1, pn2) - id.dumpIndex(t) + id.DumpIndex(t) // Look for pn1 { @@ -740,7 +740,7 @@ func IsDeleted(t *testing.T, initIdx func() *index.Index) { idx := initIdx() id := NewIndexDeps(idx) id.Fataler = t - defer id.dumpIndex(t) + defer id.DumpIndex(t) pn1 := id.NewPermanode() // delete pn1 @@ -790,7 +790,7 @@ func DeletedAt(t *testing.T, initIdx func() *index.Index) { idx := initIdx() id := NewIndexDeps(idx) id.Fataler = t - defer id.dumpIndex(t) + defer id.DumpIndex(t) pn1 := id.NewPermanode() // Test the never, ever, deleted case diff --git a/pkg/index/keys.go b/pkg/index/keys.go index 5b3fafefb..3d51b4b8c 100644 --- a/pkg/index/keys.go +++ b/pkg/index/keys.go @@ -24,7 +24,7 @@ import ( // requiredSchemaVersion is incremented every time // an index key type is added, changed, or removed. -const requiredSchemaVersion = 2 +const requiredSchemaVersion = 3 // type of key returns the identifier in k before the first ":" or "|". // (Originally we packed keys by hand and there are a mix of styles) diff --git a/pkg/index/receive.go b/pkg/index/receive.go index 40101afbd..45fea0712 100644 --- a/pkg/index/receive.go +++ b/pkg/index/receive.go @@ -26,6 +26,7 @@ import ( _ "image/png" "io" "log" + "os" "sort" "strings" "sync" @@ -126,6 +127,8 @@ func (ix *Index) commit(mm mutationMap) error { // the blobref can be trusted at this point (it's been fully consumed // and verified to match), and the sniffer has been populated. func (ix *Index) populateMutationMap(br blob.Ref, sniffer *BlobSniffer) (mutationMap, error) { + // TODO(mpl): shouldn't we remove these two from the map (so they don't get committed) when + // e.g in populateClaim we detect a bogus claim (which does not yield an error)? mm := mutationMap{ "have:" + br.String(): fmt.Sprintf("%d", sniffer.Size()), "meta:" + br.String(): fmt.Sprintf("%d|%s", sniffer.Size(), sniffer.MIMEType()), @@ -137,10 +140,6 @@ func (ix *Index) populateMutationMap(br blob.Ref, sniffer *BlobSniffer) (mutatio if err := ix.populateClaim(blob, mm); err != nil { return nil, err } - case "permanode": - //if err := mi.populatePermanode(blobRef, camli, mm); err != nil { - //return nil, err - //} case "file": if err := ix.populateFile(blob, mm); err != nil { return nil, err @@ -311,6 +310,41 @@ func (ix *Index) populateDir(b *schema.Blob, mm mutationMap) error { return nil } +// populateDeleteClaim adds to mm the entries resulting from the delete claim cl. +// It is assumed cl is a valid claim, and vr has already been verified. +func (ix *Index) populateDeleteClaim(cl schema.Claim, vr *jsonsign.VerifyRequest, mm mutationMap) { + br := cl.Blob().BlobRef() + target := cl.Target() + if !target.Valid() { + log.Print(fmt.Errorf("no valid target for delete claim %v", br)) + return + } + meta, err := ix.GetBlobMeta(target) + if err != nil { + if err == os.ErrNotExist { + // TODO: return a dependency error type, to schedule re-indexing in the future + } + log.Print(fmt.Errorf("Could not get mime type of target blob %v: %v", target, err)) + return + } + // TODO(mpl): create consts somewhere for "claim" and "permanode" as camliTypes, and use them, + // instead of hardcoding. Unless they already exist ? (didn't find them). + if meta.CamliType != "permanode" && meta.CamliType != "claim" { + log.Print(fmt.Errorf("delete claim target in %v is neither a permanode nor a claim: %v", br, meta.CamliType)) + return + } + mm.Set(keyDeleted.Key(target, cl.ClaimDateString(), br), "") + mm.Set(keyDeletes.Key(br, target), "") + if meta.CamliType == "claim" { + return + } + recentKey := keyRecentPermanode.Key(vr.SignerKeyId, cl.ClaimDateString(), br) + mm.Set(recentKey, target.String()) + attr, value := cl.Attribute(), cl.Value() + claimKey := keyPermanodeClaim.Key(target, vr.SignerKeyId, cl.ClaimDateString(), br) + mm.Set(claimKey, keyPermanodeClaim.Val(cl.ClaimType(), attr, value, vr.CamliSigner)) +} + func (ix *Index) populateClaim(b *schema.Blob, mm mutationMap) error { br := b.BlobRef() @@ -320,13 +354,6 @@ func (ix *Index) populateClaim(b *schema.Blob, mm mutationMap) error { return nil } - pnbr := claim.ModifiedPermanode() - if !pnbr.Valid() { - // A different type of claim; not modifying a permanode. - return nil - } - attr, value := claim.Attribute(), claim.Value() - vr := jsonsign.NewVerificationRequest(b.JSON(), ix.KeyFetcher) if !vr.Verify() { // TODO(bradfitz): ask if the vr.Err.(jsonsign.Error).IsPermanent() and retry @@ -337,12 +364,22 @@ func (ix *Index) populateClaim(b *schema.Blob, mm mutationMap) error { return errors.New("index: populateClaim verification failure") } verifiedKeyId := vr.SignerKeyId - mm.Set("signerkeyid:"+vr.CamliSigner.String(), verifiedKeyId) + if claim.ClaimType() == string(schema.DeleteClaim) { + ix.populateDeleteClaim(claim, vr, mm) + return nil + } + + pnbr := claim.ModifiedPermanode() + if !pnbr.Valid() { + // A different type of claim; not modifying a permanode. + return nil + } + + attr, value := claim.Attribute(), claim.Value() recentKey := keyRecentPermanode.Key(verifiedKeyId, claim.ClaimDateString(), br) mm.Set(recentKey, pnbr.String()) - claimKey := keyPermanodeClaim.Key(pnbr, verifiedKeyId, claim.ClaimDateString(), br) mm.Set(claimKey, keyPermanodeClaim.Val(claim.ClaimType(), attr, value, vr.CamliSigner)) diff --git a/pkg/publish/types.go b/pkg/publish/types.go new file mode 100644 index 000000000..8e2f43d29 --- /dev/null +++ b/pkg/publish/types.go @@ -0,0 +1,89 @@ +/* +Copyright 2013 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. +*/ + +// Package publish exposes the types and functions that can be used +// from a Go template, for publishing. +package publish + +import ( + "html/template" + + "camlistore.org/pkg/search" +) + +// SubjectPage is the data structure used when serving a +// publishing template. It contains the functions that can be called +// from the template. +type SubjectPage struct { + Header func() *PageHeader + File func() *PageFile + Members func() *PageMembers +} + +// PageHeader contains the data available to the template, +// and relevant to the page header. +type PageHeader struct { + Title string // Page title. + CSSFiles []string // Available CSS files. + JSDeps []string // Dependencies (for e.g closure) that can/should be included as javascript files. + CamliClosure template.JS // Closure namespace defined in the provided js. e.g camlistore.GalleryPage from pics.js + Subject string // Subject of this page (i.e the object which is described and published). + Meta string // All the metadata describing the subject of this page. + ViewerIsOwner bool // Whether the viewer of the page is also the owner of the displayed subject. (localhost check for now.) +} + +// PageFile contains the file related data available to the subject template, +// if the page describes some file contents. +type PageFile struct { + FileName string + Size int64 + MIMEType string + IsImage bool + DownloadURL string + ThumbnailURL string + DomID string + Nav func() *Nav +} + +// Nav holds links to the previous, next, and parent elements, +// when displaying members. +type Nav struct { + ParentPath string + PrevPath string + NextPath string +} + +// PageMembers contains the data relevant to the members if the published subject +// is a permanode with members. +type PageMembers struct { + SubjectPath string // URL prefix path to the subject (i.e the permanode). + ZipName string // Name of the downloadable zip file which contains all the members. + Members []*search.DescribedBlob // List of the members. + Description func(*search.DescribedBlob) string // Returns the description of the given member. + Title func(*search.DescribedBlob) string // Returns the title for the given member. + Path func(*search.DescribedBlob) string // Returns the url prefix path to the given the member. + DomID func(*search.DescribedBlob) string // Returns the Dom ID of the given member. + FileInfo func(*search.DescribedBlob) *MemberFileInfo // Returns some file info if the given member is a file permanode. +} + +// MemberFileInfo contains the file related data available for each member, +// if the member is the permanode for a file. +type MemberFileInfo struct { + FileName string + FileDomID string + FilePath string + FileThumbnailURL string +} diff --git a/pkg/server/publish.go b/pkg/server/publish.go index caf4d9433..98d335c36 100644 --- a/pkg/server/publish.go +++ b/pkg/server/publish.go @@ -23,6 +23,8 @@ import ( "errors" "fmt" "html" + "html/template" + "io/ioutil" "log" "net/http" "net/url" @@ -40,7 +42,7 @@ import ( "camlistore.org/pkg/httputil" "camlistore.org/pkg/jsonconfig" "camlistore.org/pkg/jsonsign/signhandler" - "camlistore.org/pkg/osutil" + "camlistore.org/pkg/publish" "camlistore.org/pkg/schema" "camlistore.org/pkg/search" "camlistore.org/pkg/types/camtypes" @@ -56,7 +58,10 @@ type PublishHandler struct { Cache blobserver.Storage // or nil sc ScaledImage // cache of scaled images, optional - JSFiles, CSSFiles []string + CSSFiles []string + // goTemplate is the go html template used for publishing. + goTemplate *template.Template + closureName string // Name of the closure object used to decorate the published page. handlerFinder blobserver.FindHandlerByTyper @@ -81,8 +86,9 @@ func newPublishFromConfig(ld blobserver.Loader, conf jsonconfig.Obj) (h http.Han handlerFinder: ld, } ph.RootName = conf.RequiredString("rootName") - ph.JSFiles = conf.OptionalList("js") + jsFiles := conf.OptionalList("js") ph.CSSFiles = conf.OptionalList("css") + goTemplateFile := conf.RequiredString("goTemplate") blobRoot := conf.RequiredString("blobRoot") searchRoot := conf.RequiredString("searchRoot") cachePrefix := conf.OptionalString("cache", "") @@ -180,9 +186,47 @@ func newPublishFromConfig(ld blobserver.Loader, conf jsonconfig.Obj) (h http.Han return nil, fmt.Errorf(`Invalid "sourceRoot" value of %q: %v"`, ph.sourceRoot, err) } + ph.goTemplate, err = goTemplate(goTemplateFile) + if err != nil { + return nil, err + } + ph.setClosureName(jsFiles) + return ph, nil } +func goTemplate(templateFile string) (*template.Template, error) { + if filepath.Base(templateFile) != templateFile { + hint := fmt.Sprintf("The file should either be embedded or placed in %s.", + filepath.FromSlash("server/camlistored/ui")) + return nil, fmt.Errorf("Unsupported path %v for template. %s", templateFile, hint) + } + f, err := uistatic.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 +} + +// setClosureName sets ph.closureName with the first found closure +// namespace provided in jsFiles. +func (ph *PublishHandler) setClosureName(jsFiles []string) { + for _, v := range jsFiles { + if ph.closureName == "" { + if cl := camliClosurePage(v); cl != "" { + ph.closureName = cl + break + } + } + } +} + func (ph *PublishHandler) makeClosureHandler(root string) (http.Handler, error) { return makeClosureHandler(root, "publish") } @@ -413,7 +457,7 @@ func (pr *publishRequest) serveHTTP() { switch pr.SubresourceType() { case "": - pr.serveSubject() + pr.serveSubjectTemplate() case "b": // TODO: download a raw blob case "f": // file download @@ -473,12 +517,7 @@ var provCamliRx = regexp.MustCompile(`^goog\.(provide)\(['"]camlistore\.(.*)['"] // and if yes, if it provides a page in the camlistore namespace. // It returns that page name, or the empty string otherwise. func camliClosurePage(filename string) string { - camliRootPath, err := osutil.GoPackagePath("camlistore.org") - if err != nil { - return "" - } - fullpath := filepath.Join(camliRootPath, "server", "camlistored", "ui", filename) - f, err := os.Open(fullpath) + f, err := uistatic.Files.Open(filename) if err != nil { return "" } @@ -518,37 +557,6 @@ func (pr *publishRequest) serveZip() { zh.ServeHTTP(pr.rw, pr.req) } -// serveHeader serves the html header with the relevant title, javascript -// and css includes. It is meant to be called from serveSubject. -func (pr *publishRequest) serveHeader(title, camliClosurePage string) { - pr.pf("\n\n\n %s\n", html.EscapeString(title)) - if camliClosurePage != "" && pr.ViewerIsOwner() { - pr.pf(" \n", pr.staticPath("closure/goog/base.js")) - pr.pf(" \n", pr.staticPath("deps.js")) - pr.pf(" \n", pr.base+"?camli.mode=config&var=CAMLISTORE_CONFIG") - pr.pf(" \n", pr.staticPath("base64.js")) - pr.pf(" \n", pr.staticPath("Crypto.js")) - pr.pf(" \n", pr.staticPath("SHA1.js")) - pr.pf("\n", camliClosurePage) - } - for _, filename := range pr.ph.CSSFiles { - pr.pf(" \n", pr.staticPath(filename)) - } -} - -// serveMeta serves all the described meta data about the published items, -// within the html header. It is meant to be called from serveSubject. -func (pr *publishRequest) serveMeta(des map[string]*search.DescribedBlob) { - pr.pf(" \n\n") -} - -// TODO(mpl): use those everywhere else const ( resSeparator = "/-" digestPrefix = "h" @@ -631,28 +639,115 @@ func (pr *publishRequest) parent() (parentPath string, parentBlobRef blob.Ref, e return parentPath, parentBlobRef, nil } -// serveNav serves some navigation links (prev, next, up) if the -// pr.subject is member of a collection (its parent has members). -// It is meant to be called from serveFile. -func (pr *publishRequest) serveNav() error { +func (pr *publishRequest) cssFiles() []string { + files := []string{} + for _, filename := range pr.ph.CSSFiles { + files = append(files, pr.staticPath(filename)) + } + return files +} + +// jsDeps returns the list of paths that should be included +// as javascript files in the published page to enable and use +// additional javascript closure code. +func (pr *publishRequest) jsDeps() []string { + var js []string + closureDeps := []string{ + "closure/goog/base.js", + "deps.js", + // TODO(mpl): fix the deps generator and/or the SHA1.js etc files so they get into deps.js and we + // do not even have to include them here. detection fails for them because the provide statement + // is not at the beginning of the line. + // Not doing it right away because it might have consequences for the rest of the ui I suppose. + "base64.js", + "Crypto.js", + "SHA1.js", + } + for _, v := range closureDeps { + js = append(js, pr.staticPath(v)) + } + js = append(js, pr.base+"?camli.mode=config&var=CAMLISTORE_CONFIG") + return js +} + +// subjectHeader returns the PageHeader corresponding to the described subject. +func (pr *publishRequest) subjectHeader(described map[string]*search.DescribedBlob) *publish.PageHeader { + subdes := described[pr.subject.String()] + header := &publish.PageHeader{ + Title: html.EscapeString(subdes.Title()), + CSSFiles: pr.cssFiles(), + Meta: func() string { + jsonRes, _ := json.MarshalIndent(described, "", " ") + return string(jsonRes) + }(), + Subject: pr.subject.String(), + } + header.JSDeps = pr.jsDeps() + header.CamliClosure = template.JS("camlistore." + pr.ph.closureName) + if pr.ViewerIsOwner() { + header.ViewerIsOwner = true + } + return header +} + +// subjectFile returns the relevant PageFile if the described subject is a file permanode. +func (pr *publishRequest) subjectFile(described map[string]*search.DescribedBlob) (*publish.PageFile, error) { + subdes := described[pr.subject.String()] + contentRef, ok := subdes.ContentRef() + if !ok { + return nil, nil + } + fileDes, err := pr.dr.DescribeSync(contentRef) + if err != nil { + return nil, fmt.Errorf("Could not describe %v: %v", contentRef, err) + } + path := []blob.Ref{pr.subject, contentRef} + downloadURL := pr.SubresFileURL(path, fileDes.File.FileName) + thumbnailURL := "" + if fileDes.File.IsImage() { + thumbnailURL = pr.SubresThumbnailURL(path, fileDes.File.FileName, 600) + } + fileName := html.EscapeString(fileDes.File.FileName) + return &publish.PageFile{ + FileName: fileName, + Size: fileDes.File.Size, + MIMEType: fileDes.File.MIMEType, + IsImage: fileDes.File.IsImage(), + DownloadURL: downloadURL, + ThumbnailURL: thumbnailURL, + DomID: contentRef.DomID(), + Nav: func() *publish.Nav { + nv, err := pr.fileNavigation() + if err != nil { + log.Print(err) + return nil + } + return nv + }, + }, nil +} + +func (pr *publishRequest) fileNavigation() (*publish.Nav, error) { // first get the parent path and blob parentPath, parentbr, err := pr.parent() if err != nil { - return fmt.Errorf("Errors building nav links for %s: %v", pr.subject, err) + return nil, fmt.Errorf("Could not get subject %v's parent's info: %v", pr.subject, err) + } + parentNav := strings.TrimSuffix(parentPath, resSeparator) + fileNav := &publish.Nav{ + ParentPath: parentNav, } - parentNav := fmt.Sprintf("[up]", strings.TrimSuffix(parentPath, resSeparator)) // describe the parent so we get the siblings (members of the parent) dr := pr.ph.Search.NewDescribeRequest() dr.Describe(parentbr, 3) parentRes, err := dr.Result() if err != nil { - return fmt.Errorf("Errors loading %s, permanode %s: %v, %#v", pr.req.URL, pr.subject, err, err) + return nil, fmt.Errorf("Could not \"deeply\" describe subject %v's parent %v: %v", pr.subject, parentbr, err) } members := parentRes[parentbr.String()].Members() if len(members) == 0 { - pr.pf("
[up]
", parentNav) - return nil + return fileNav, nil } pos := 0 @@ -669,68 +764,27 @@ func (pr *publishRequest) serveNav() error { if pos < len(members)-1 { next = members[pos+1].BlobRef } - if prev.Valid() || next.Valid() { - var prevNav, nextNav string - if prev.Valid() { - prevNav = fmt.Sprintf("[prev]", - parentPath, prev.DigestPrefix(10)) - } - if next.Valid() { - nextNav = fmt.Sprintf("[next]", - parentPath, next.DigestPrefix(10)) - } - pr.pf("
%s %s %s
", parentNav, prevNav, nextNav) + if !prev.Valid() && !next.Valid() { + return fileNav, nil } - - return nil + if prev.Valid() { + fileNav.PrevPath = fmt.Sprintf("%s/%s%s", parentPath, digestPrefix, prev.DigestPrefix(10)) + } + if next.Valid() { + fileNav.NextPath = fmt.Sprintf("%s/%s%s", parentPath, digestPrefix, next.DigestPrefix(10)) + } + return fileNav, nil } -// serveFile serves the relevant view when the subject in serveSubject -// is a permanode with some content cref. It is meant to be called -// from serveSubject. -func (pr *publishRequest) serveFile(cref blob.Ref) error { - des, err := pr.dr.DescribeSync(cref) - if err != nil { - pr.pf("

Error serving file

") - return fmt.Errorf("Could not describe %v: %v", cref, err) +// subjectMembers returns the relevant PageMembers if the described subject is a permanode with members. +func (pr *publishRequest) subjectMembers(resMap map[string]*search.DescribedBlob) (*publish.PageMembers, error) { + subdes := resMap[pr.subject.String()] + members := subdes.Members() + if len(members) == 0 { + return nil, nil } - if des.File != nil { - path := []blob.Ref{pr.subject, cref} - downloadURL := pr.SubresFileURL(path, des.File.FileName) - pr.pf("
File: %s, %d bytes, type %s
", - html.EscapeString(des.File.FileName), - des.File.Size, - des.File.MIMEType) - if des.File.IsImage() { - pr.pf("", - downloadURL, - pr.SubresThumbnailURL(path, des.File.FileName, 600)) - } - pr.pf("", - cref.DomID(), - downloadURL) - } - if strings.Contains(pr.subjectBasePath, resSeparator) { - // this permanode has a "parent" collection. - // so we send a deep request on the parent in order to get some info - // about the siblings and build some "prev" and "next" nav links. - // TODO(mpl): nav links everywhere, not just when showing a permanode - // with some content. - err := pr.serveNav() - if err != nil { - pr.pf("

Error building navs links

") - return err - } - } - return nil -} - -// serveMembers serves the relevant view when the subject in serveSubject -// is a collection (permanode with members). It is meant to be called -// from serveSubject. -func (pr *publishRequest) serveMembers(title string, members []*search.DescribedBlob) { zipName := "" - if title == "" { + if title := subdes.Title(); title == "" { zipName = "download.zip" } else { zipName = title + ".zip" @@ -739,91 +793,95 @@ func (pr *publishRequest) serveMembers(title string, members []*search.Described if !strings.Contains(subjectPath, "/-/") { subjectPath += "/-" } - pr.pf("
%s
\n", subjectPath, - html.EscapeString(url.QueryEscape(zipName)), html.EscapeString(zipName)) - pr.pf("\n") + return des + }, + Title: func(member *search.DescribedBlob) string { + memberTitle := member.Title() + if memberTitle == "" { + memberTitle = member.BlobRef.DigestPrefix(10) + } + return html.EscapeString(memberTitle) + }, + Path: func(member *search.DescribedBlob) string { + return pr.memberPath(member.BlobRef) + }, + DomID: func(member *search.DescribedBlob) string { + return member.DomID() + }, + FileInfo: func(member *search.DescribedBlob) *publish.MemberFileInfo { + if path, fileInfo, ok := member.PermanodeFile(); ok { + info := &publish.MemberFileInfo{ + FileName: fileInfo.FileName, + FileDomID: path[len(path)-1].DomID(), + FilePath: html.EscapeString(pr.SubresFileURL(path, fileInfo.FileName)), + } + if fileInfo.IsImage() { + info.FileThumbnailURL = pr.SubresThumbnailURL(path, fileInfo.FileName, 200) + } + return info + } + return nil + }, + }, nil } -func (pr *publishRequest) serveSubject() { +// serveSubjectTemplate creates the funcs to generate the PageHeader, PageFile, +// and pageMembers that can be used by the subject template, and serves the template. +func (pr *publishRequest) serveSubjectTemplate() { dr := pr.ph.Search.NewDescribeRequest() dr.Describe(pr.subject, 3) res, err := dr.Result() if err != nil { log.Printf("Errors loading %s, permanode %s: %v, %#v", pr.req.URL, pr.subject, err, err) - pr.pf("

Errors loading.

") + http.Error(pr.rw, "Error loading describe request", http.StatusInternalServerError) return } - subdes := res[pr.subject.String()] - if subdes.CamliType == "file" { pr.serveFileDownload(subdes) return } - title := subdes.Title() - - // HTML header + Javascript - var camliPage string - // TODO(mpl): We are only using the first .js file, and expecting it to be - // using closure. We want to be more customizable in the long run and enable - // some sort of templating mechanism. - if len(pr.ph.JSFiles) > 0 { - camliPage = camliClosurePage(pr.ph.JSFiles[0]) + headerFunc := func() *publish.PageHeader { + return pr.subjectHeader(res) } - pr.serveHeader(title, camliPage) - pr.serveMeta(res) - pr.pf("\n") - if title != "" { - pr.pf("

%s

\n", html.EscapeString(title)) - } - defer pr.pf("\n\n") - - if cref, ok := subdes.ContentRef(); ok { - err = pr.serveFile(cref) + fileFunc := func() *publish.PageFile { + file, err := pr.subjectFile(res) if err != nil { - log.Print(err) - return + log.Printf("%v", err) + return nil } - } else { - if members := subdes.Members(); len(members) > 0 { - pr.serveMembers(title, members) + return file + } + membersFunc := func() *publish.PageMembers { + members, err := pr.subjectMembers(res) + if err != nil { + log.Printf("%v", err) + return nil } + return members + } + page := &publish.SubjectPage{ + Header: headerFunc, + File: fileFunc, + Members: membersFunc, } - if camliPage != "" && pr.ViewerIsOwner() { - pr.pf("\n") + err = pr.ph.goTemplate.Execute(pr.rw, page) + if err != nil { + log.Printf("Error serving subject template: %v", err) + http.Error(pr.rw, "Error serving template", http.StatusInternalServerError) + return } } diff --git a/pkg/serverconfig/genconfig.go b/pkg/serverconfig/genconfig.go index efab31280..1538f82c1 100644 --- a/pkg/serverconfig/genconfig.go +++ b/pkg/serverconfig/genconfig.go @@ -44,7 +44,7 @@ type configPrefixesParams struct { blobPath string searchOwner blob.Ref shareHandlerPath string - flickr map[string]interface{} + flickr string } var ( @@ -62,7 +62,7 @@ func addPublishedConfig(prefixes jsonconfig.Obj, return nil, fmt.Errorf("Wrong type for %s; was expecting map[string]interface{}, got %T", k, v) } rootName := strings.Replace(k, "/", "", -1) + "Root" - rootPermanode, template, style := "", "", "" + rootPermanode, goTemplate, style, js := "", "", "", "" for pk, pv := range p { val, ok := pv.(string) if !ok { @@ -71,16 +71,18 @@ func addPublishedConfig(prefixes jsonconfig.Obj, switch pk { case "rootPermanode": rootPermanode = val - case "template": - template = val + case "goTemplate": + goTemplate = val case "style": style = val + case "js": + js = val default: return nil, fmt.Errorf("Unexpected key %q in config for %s", pk, k) } } - if rootPermanode == "" || template == "" { - return nil, fmt.Errorf("Missing key in configuration for %s, need \"rootPermanode\" and \"template\"", k) + if rootPermanode == "" || goTemplate == "" { + return nil, fmt.Errorf("Missing key in configuration for %s, need \"rootPermanode\" and \"goTemplate\"", k) } ob := map[string]interface{}{} ob["handler"] = "publish" @@ -94,19 +96,10 @@ func addPublishedConfig(prefixes jsonconfig.Obj, if sourceRoot != "" { handlerArgs["sourceRoot"] = sourceRoot } - switch template { - case "gallery": - if style == "" { - style = "pics.css" - } - handlerArgs["css"] = []interface{}{style} - handlerArgs["js"] = []interface{}{"pics.js"} - handlerArgs["scaledImage"] = "lrucache" - case "blog": - if style != "" { - handlerArgs["css"] = []interface{}{style} - } - } + handlerArgs["goTemplate"] = goTemplate + handlerArgs["css"] = []interface{}{style} + handlerArgs["js"] = []interface{}{js} + handlerArgs["scaledImage"] = "lrucache" ob["handlerArgs"] = handlerArgs prefixes[k] = ob pubPrefixes = append(pubPrefixes, k) @@ -445,10 +438,9 @@ func genLowLevelPrefixes(params *configPrefixesParams, ownerName string) (m json } } - if len(params.flickr) > 0 { + if params.flickr != "" { m["/importer-flickr/"] = map[string]interface{}{ - "handler": "importer-flickr", - "handlerArgs": params.flickr, + "apiKey": params.flickr, } } @@ -539,7 +531,7 @@ func genLowLevelConfig(conf *Config) (lowLevelConf *Config, err error) { kvFile = conf.OptionalString("kvIndexFile", "") // Importer options - flickr = conf.OptionalObject("flickr") + flickr = conf.OptionalString("flickr", "") _ = conf.OptionalList("replicateTo") publish = conf.OptionalObject("publish") diff --git a/server/camlistored/ui/blog.html b/server/camlistored/ui/blog.html new file mode 100644 index 000000000..f00297814 --- /dev/null +++ b/server/camlistored/ui/blog.html @@ -0,0 +1,6 @@ + + + + TODO + + diff --git a/server/camlistored/ui/gallery.html b/server/camlistored/ui/gallery.html new file mode 100644 index 000000000..85dbab549 --- /dev/null +++ b/server/camlistored/ui/gallery.html @@ -0,0 +1,67 @@ + + +{{if $header := call .Header}} + + {{$header.Title}} + {{range $js := $header.JSDeps}} + + {{end}} + {{if $header.CamliClosure}} + + {{end}} + {{range $css := $header.CSSFiles}} + + {{end}} + + + +

{{$header.Title}}

+ {{if $file := call .File}} +
File: {{$file.FileName}}, {{$file.Size}} bytes, type {{$file.MIMEType}}
+ {{if $file.IsImage}} + + {{end}} + + {{if $nav := call $file.Nav}} +
+ {{if $prev := $nav.PrevPath}}[prev] {{end}} + {{if $up := $nav.ParentPath}}[up] {{end}} + {{if $next := $nav.NextPath}}[next] {{end}} +
+ {{end}} + {{else}} + {{if $membersData := call .Members}} +
{{html $membersData.ZipName}}
+ + + {{end}} + {{end}} + {{if $header.CamliClosure}} + {{if $header.ViewerIsOwner}} + + {{end}} + {{end}} +{{end}} + + diff --git a/website/content/docs/server-config b/website/content/docs/server-config index fa08b0f1a..92eae150b 100644 --- a/website/content/docs/server-config +++ b/website/content/docs/server-config @@ -23,7 +23,7 @@ web browser and restart the server.

  • baseURL: Optional. If non-empty, this is the root of your URL prefix for your Camlistore server. Useful for when running behind a reverse proxy. Should not end in a slash. e.g. https://yourserver.example.com
  • -
  • flickr: Optional, and doesn't do anything yet. Support for continuous import from Flickr. Enter two child keys: appKey and appSecret, which you can get by filling out this form.
  • +
  • flickr: Optional, and doesn't do anything yet. Support for continuous import from Flickr. Should be an API key and secret formatted as key:secret. Get yours by filling out this form.
  • https: if "true", HTTPS is used
      @@ -67,18 +67,21 @@ to pkg/genconfig welcome.

      config, as used by devcam server.

      Publishing options

      -

      Although limited, publishing can be configured through the publish key. There is only support for an image gallery view (even though it will display thumbnails for other kinds of items), which is not really customizable. Here is an example of a value if one wanted to publish some items under /pics/:

      +

      Camlistore uses Go html templates to publish pages, and publishing can be configured through the publish key. There is already support for an image gallery view, which can be enabled similarly to the example below (obviously, the rootPermanode will be different).

      -{
      -  "/pics/": {
      -    "rootPermanode": "sha1-09888624be84fcb3ae67e8aa2f29682b4ff515d7",
      -    "style": "pics.css",
      -    "template": "gallery"
      -  }
      +"publish": {
      +	"/pics/": {
      +		"rootPermanode": "sha1-6cbe9e1c35e854eab028cba43d099d35ceae0de8",
      +		"style": "pics.css",
      +		"js": "pics.js",
      +		"goTemplate": "gallery.html"
      +	}
       }
       

      One can create any permanode with camput or the UI and use it as the rootPermanode.

      +

      Please see the publishing README if you want to make/contribute more publishing views.

      +

      Windows