publisher app

http://camlistore.org/issue/365

Change-Id: I281fdcbbe6a2bdf15607e75a21bc93b453f82c85
This commit is contained in:
mpl 2014-06-14 22:14:34 +02:00
parent 173adebc19
commit a34f9e2669
30 changed files with 1399 additions and 1592 deletions

1
.gitignore vendored
View File

@ -22,6 +22,7 @@ bin/cam*
bin/devcam
bin/*_*
bin/hello
bin/publisher
tmp
server/camlistored/newui/all.js
server/camlistored/newui/all.js.map

View File

@ -0,0 +1,28 @@
/*
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.
*/
/*
#fileembed pattern .+\.(js|css|html|png|svg)$
*/
package main
import (
"camlistore.org/pkg/fileembed"
)
// TODO(mpl): appengine case
var Files = &fileembed.Files{}

View File

@ -3,12 +3,6 @@
{{if $header := call .Header}}
<head>
<title>{{$header.Title}}</title>
{{range $js := $header.JSDeps}}
<script src='{{$js}}'></script>
{{end}}
{{if $header.CamliClosure}}
<script>goog.require('{{$header.CamliClosure}}');</script>
{{end}}
{{range $css := $header.CSSFiles}}
<link rel='stylesheet' type='text/css' href='{{$css}}'>
{{end}}
@ -54,14 +48,6 @@
</ul>
{{end}}
{{end}}
{{if $header.CamliClosure}}
{{if $header.ViewerIsOwner}}
<script>
var page = new {{$header.CamliClosure}}(CAMLISTORE_CONFIG);
page.decorate(document.body);
</script>
{{end}}
{{end}}
{{end}}
</body>
</html>

1004
app/publisher/main.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
/*
Copyright 2011 Google Inc.
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.
@ -14,15 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package server
package main
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
camliClient "camlistore.org/pkg/client"
"camlistore.org/pkg/httputil"
"camlistore.org/pkg/index"
"camlistore.org/pkg/index/indextest"
@ -113,6 +115,29 @@ func setupContent(rootName string) *indextest.IndexDeps {
return idxd
}
type fakeClient struct {
*camliClient.Client // for blob.Fetcher
sh *search.Handler
}
func (fc *fakeClient) Search(req *search.SearchQuery) (*search.SearchResult, error) {
return fc.sh.Query(req)
}
func (fc *fakeClient) Describe(req *search.DescribeRequest) (*search.DescribeResponse, error) {
return fc.sh.Describe(req)
}
func (fc *fakeClient) GetJSON(url string, data interface{}) error {
// no need to implement
return nil
}
func (fc *fakeClient) Post(url string, bodyType string, body io.Reader) error {
// no need to implement
return nil
}
func TestPublishURLs(t *testing.T) {
rootName := "foo"
idxd := setupContent(rootName)
@ -122,9 +147,14 @@ func TestPublishURLs(t *testing.T) {
t.Fatalf("error slurping index to memory: %v", err)
}
sh.SetCorpus(corpus)
ph := &PublishHandler{
RootName: rootName,
Search: sh,
cl := camliClient.New("http://whatever.fake")
fcl := &fakeClient{cl, sh}
ph := &publishHandler{
rootName: rootName,
cl: fcl,
}
if err := ph.initRootNode(); err != nil {
t.Fatalf("initRootNode: %v", err)
}
for ti, tt := range publishURLTests {
@ -137,9 +167,12 @@ func TestPublishURLs(t *testing.T) {
pfxh := &httputil.PrefixHandler{
Prefix: "/pics/",
Handler: http.HandlerFunc(func(_ http.ResponseWriter, req *http.Request) {
pr := ph.NewRequest(rw, req)
pr, err := ph.NewRequest(rw, req)
if err != nil {
t.Fatalf("test #%d, NewRequest: %v", ti, err)
}
err := pr.findSubject()
err = pr.findSubject()
if tt.subject != "" {
if err != nil {
t.Errorf("test #%d, findSubject: %v", ti, err)
@ -168,9 +201,11 @@ func TestPublishMembers(t *testing.T) {
t.Fatalf("error slurping index to memory: %v", err)
}
sh.SetCorpus(corpus)
ph := &PublishHandler{
RootName: rootName,
Search: sh,
cl := camliClient.New("http://whatever.fake")
fcl := &fakeClient{cl, sh}
ph := &publishHandler{
rootName: rootName,
cl: fcl,
}
rw := httptest.NewRecorder()
@ -179,17 +214,17 @@ func TestPublishMembers(t *testing.T) {
pfxh := &httputil.PrefixHandler{
Prefix: "/pics/",
Handler: http.HandlerFunc(func(_ http.ResponseWriter, req *http.Request) {
pr := ph.NewRequest(rw, req)
dr := pr.ph.Search.NewDescribeRequest()
dr.Describe(pr.subject, 3)
res, err := dr.Result()
pr, err := ph.NewRequest(rw, req)
if err != nil {
t.Errorf("Result: %v", err)
return
t.Fatalf("NewRequest: %v", err)
}
members, err := pr.subjectMembers(res)
res, err := pr.ph.deepDescribe(pr.subject)
if err != nil {
t.Fatalf("deepDescribe: %v", err)
}
members, err := pr.subjectMembers(res.Meta)
if len(members.Members) != 2 {
t.Errorf("Expected two members in publish root (one camlipath, one camlimember), got %d", len(members.Members))
}

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package server
package main
import (
"archive/zip"
@ -31,12 +31,13 @@ import (
"camlistore.org/pkg/httputil"
"camlistore.org/pkg/schema"
"camlistore.org/pkg/search"
"camlistore.org/pkg/types/camtypes"
)
type zipHandler struct {
fetcher blob.Fetcher
search *search.Handler
// the "parent" permanode of everything to zip.
cl client // Used for search and describe requests.
// root is the "parent" permanode of everything to zip.
// Either a directory permanode, or a permanode with members.
root blob.Ref
// Optional name to use in the response header
@ -58,17 +59,47 @@ func (s sortedFiles) Less(i, j int) bool { return s[i].path < s[j].path }
func (s sortedFiles) Len() int { return len(s) }
func (s sortedFiles) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
func (zh *zipHandler) describeMembers(br blob.Ref) (*search.DescribeResponse, error) {
res, err := zh.cl.Search(&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"},
},
},
},
Limit: -1,
})
if err != nil {
return nil, fmt.Errorf("Could not 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
}
// blobList returns the list of file blobs "under" dirBlob.
// It traverses permanode directories and permanode with members (collections).
func (zh *zipHandler) blobList(dirPath string, dirBlob blob.Ref) ([]*blobFile, error) {
dr := zh.search.NewDescribeRequest()
dr.Describe(dirBlob, 3)
res, err := dr.Result()
// dr := zh.search.NewDescribeRequest()
// dr.Describe(dirBlob, 3)
// res, err := dr.Result()
// if err != nil {
// return nil, fmt.Errorf("Could not describe %v: %v", dirBlob, err)
// }
res, err := zh.describeMembers(dirBlob)
if err != nil {
return nil, fmt.Errorf("Could not describe %v: %v", dirBlob, err)
return nil, err
}
described := res[dirBlob.String()]
described := res.Meta[dirBlob.String()]
members := described.Members()
dirBlobPath, _, isDir := described.PermanodeDir()
if len(members) == 0 && !isDir {
@ -85,13 +116,13 @@ func (zh *zipHandler) blobList(dirPath string, dirBlob blob.Ref) ([]*blobFile, e
return list, nil
}
for _, member := range members {
if fileBlobPath, fileInfo, ok := member.PermanodeFile(); ok {
if fileBlobPath, fileInfo, ok := getFileInfo(member.BlobRef, res.Meta); ok {
// file
list = append(list,
&blobFile{fileBlobPath[1], path.Join(dirPath, fileInfo.FileName)})
continue
}
if dirBlobPath, dirInfo, ok := member.PermanodeDir(); ok {
if dirBlobPath, dirInfo, ok := getDirInfo(member.BlobRef, res.Meta); ok {
// directory
newZipRoot := dirBlobPath[1]
children, err := zh.blobsFromDir(
@ -258,3 +289,21 @@ func (zh *zipHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
return
}
}
// TODO(mpl): refactor with getFileInfo
func getDirInfo(item blob.Ref, peers map[string]*search.DescribedBlob) (path []blob.Ref, di *camtypes.FileInfo, ok bool) {
described := peers[item.String()]
if described == nil ||
described.Permanode == nil ||
described.Permanode.Attr == nil {
return
}
contentRef := described.Permanode.Attr.Get("camliContent")
if contentRef == "" {
return
}
if cdes := peers[contentRef]; cdes != nil && cdes.Dir != nil {
return []blob.Ref{described.BlobRef, cdes.BlobRef}, cdes.Dir, true
}
return
}

View File

@ -28,31 +28,17 @@
}
},
"/blog/": {
"enabled": ["_env", "${CAMLI_PUBLISH_ENABLED}"],
"handler": "publish",
"handlerArgs": {
"rootName": "dev-blog-root",
"blobRoot": "/bs/",
"searchRoot": "/my-search/",
"cache": "/cache/",
"goTemplate": "blog.html",
"devBootstrapPermanodeUsing": "/sighelper/"
}
},
"/pics/": {
"handler": "app",
"enabled": ["_env", "${CAMLI_PUBLISH_ENABLED}"],
"handler": "publish",
"handlerArgs": {
"rootName": "dev-pics-root",
"blobRoot": "/bs/",
"searchRoot": "/my-search/",
"cache": "/cache/",
"css": ["pics.css"],
"js": ["pics.js"],
"goTemplate": "gallery.html",
"devBootstrapPermanodeUsing": "/sighelper/"
"program": "publisher",
"appConfig": {
"camliRoot": "dev-pics-root",
"sourceRoot": ["_env", "${CAMLI_DEV_CAMLI_ROOT}", ""],
"cacheRoot": ["_env", "${CAMLI_ROOT_CACHE}"],
"goTemplate": "gallery.html"
}
}
},
@ -72,8 +58,7 @@
"scaledImage": {
"type": "kv",
"file": ["_env", "${CAMLI_ROOT_CACHE}/thumbnails.kv", ""]
},
"publishRoots": ["/blog/", "/pics/"]
}
}
},

View File

@ -55,7 +55,7 @@ type serverCmd struct {
fullClosure bool
mini bool
publish bool // whether to start the publish handlers
publish bool // whether to build and start the publisher app(s)
hello bool // whether to build and start the hello demo app
openBrowser bool
@ -82,7 +82,7 @@ func init() {
flags.BoolVar(&cmd.tls, "tls", false, "Use TLS.")
flags.BoolVar(&cmd.wipe, "wipe", false, "Wipe the blobs on disk and the indexer.")
flags.BoolVar(&cmd.debug, "debug", false, "Enable http debugging.")
flags.BoolVar(&cmd.publish, "publish", true, "Enable publish handlers")
flags.BoolVar(&cmd.publish, "publish", true, "Enable publisher app(s)")
flags.BoolVar(&cmd.hello, "hello", false, "Enable hello (demo) app")
flags.BoolVar(&cmd.mini, "mini", false, "Enable minimal mode, where all optional features are disabled. (Currently just publishing)")
@ -384,6 +384,9 @@ func (c *serverCmd) RunCommand(args []string) error {
if c.hello {
targets = append(targets, filepath.Join("app", "hello"))
}
if c.publish {
targets = append(targets, filepath.Join("app", "publisher"))
}
for _, name := range targets {
err := build(name)
if err != nil {

View File

@ -1,14 +1,13 @@
Camlistore uses Go html templates (http://golang.org/pkg/text/template/) to publish pages.
Camlistore delegates publishing to the publisher server application, which 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.
Resources for publishing, such as go templates, javascript and css files should be placed in the application source directory - app/publisher/ - so they can be served directly when using the dev server or automatically embedded in production.
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):
You should then specify the Go template to be used through the configuration file. The CSS files are automatically all available to the app. For example, there already is a go template (gallery.html), 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 configure publishing for an image gallery in the server config ($HOME/.config/camlistore/server-config.json):
"publish": {
"/pics/": {
"rootPermanode": "sha1-6cbe9e1c35e854eab028cba43d099d35ceae0de8",
"style": "pics.css",
"js": "pics.js",
"camliRoot": "mypics",
"cacheRoot": "/home/joe/var/camlistore/blobs/cache",
"goTemplate": "gallery.html"
}
}

View File

@ -169,6 +169,7 @@ func main() {
"camlistore.org/cmd/camtool",
"camlistore.org/server/camlistored",
"camlistore.org/app/hello",
"camlistore.org/app/publisher",
}
switch *targets {
case "*":
@ -345,7 +346,7 @@ func buildSrcPath(fromSrc string) string {
// kept in between runs.
func genEmbeds() error {
cmdName := exeName(filepath.Join(buildGoPath, "bin", "genfileembed"))
for _, embeds := range []string{"server/camlistored/ui", "pkg/server", "third_party/react", "third_party/glitch", "third_party/fontawesome"} {
for _, embeds := range []string{"server/camlistored/ui", "pkg/server", "third_party/react", "third_party/glitch", "third_party/fontawesome", "app/publisher"} {
embeds := buildSrcPath(embeds)
args := []string{embeds}
cmd := exec.Command(cmdName, args...)

View File

@ -785,6 +785,22 @@ func (c *Client) GetJSON(url string, data interface{}) error {
return httputil.DecodeJSON(resp, data)
}
// Post is like http://golang.org/pkg/net/http/#Client.Post
// but with implementation details like gated requests. The
// URL's host must match the client's configured server.
func (c *Client) Post(url string, bodyType string, body io.Reader) error {
if !strings.HasPrefix(url, c.discoRoot()) {
return fmt.Errorf("wrong URL (%q) for this server", url)
}
req := c.newRequest("POST", url, body)
req.Header.Set("Content-Type", bodyType)
res, err := c.expect2XX(req)
if err != nil {
return err
}
return res.Body.Close()
}
func (c *Client) newRequest(method, url string, body ...io.Reader) *http.Request {
var bodyR io.Reader
if len(body) > 0 {

View File

@ -456,32 +456,6 @@ func (b *DescribedBlob) peerBlob(br blob.Ref) *DescribedBlob {
return &DescribedBlob{Request: b.Request, BlobRef: br, Stub: true}
}
// HasSecureLinkTo returns true if there's a valid link from this blob
// to the other blob. This is used in access control (hence the
// somewhat redundant "Secure" in the name) and should be paranoid
// against e.g. random user/attacker-control attributes making links
// to other blobs.
//
// TODO: don't linear scan here. rewrite this in terms of ResolvePrefixHop,
// passing down some policy perhaps? or maybe that's enough.
func (b *DescribedBlob) HasSecureLinkTo(other blob.Ref) bool {
if b == nil || !other.Valid() {
return false
}
ostr := other.String()
if b.Permanode != nil {
if b.Permanode.Attr.Get("camliContent") == ostr {
return true
}
for _, mstr := range b.Permanode.Attr["camliMember"] {
if mstr == ostr {
return true
}
}
}
return false
}
func (b *DescribedBlob) isPermanode() bool {
return b.Permanode != nil
}

View File

@ -639,53 +639,6 @@ func (sh *Handler) serveClaims(rw http.ResponseWriter, req *http.Request) {
httputil.ReturnJSON(rw, res)
}
// 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 (sh *Handler) 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)
}
dr := sh.NewDescribeRequest()
dr.Describe(parent, 1)
res, err := dr.Result()
if err != nil {
return
}
des, ok := res[parent.String()]
if !ok {
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
}
}
_, err := dr.DescribeSync(cr)
if err != nil {
return blob.Ref{}, fmt.Errorf("Failed to describe content %q of parent %q", cr, parent)
}
if _, _, ok := des.PermanodeDir(); ok {
return sh.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 (sh *Handler) serveFiles(rw http.ResponseWriter, req *http.Request) {
ret := jsonMap()
defer httputil.ReturnJSON(rw, ret)

View File

@ -63,8 +63,8 @@ type ImageHandler struct {
Cache blobserver.Storage // optional
MaxWidth, MaxHeight int
Square bool
thumbMeta *thumbMeta // optional cache for scaled images
resizeSem *syncutil.Sem
ThumbMeta *ThumbMeta // optional cache index for scaled images
ResizeSem *syncutil.Sem // Limit peak RAM used by concurrent image thumbnail calls.
}
type subImager interface {
@ -117,7 +117,7 @@ func (ih *ImageHandler) cacheScaled(thumbBytes []byte, name string) error {
if err != nil {
return err
}
ih.thumbMeta.Put(name, br)
ih.ThumbMeta.Put(name, br)
return nil
}
@ -178,7 +178,7 @@ func cacheKey(bref string, width int, height int) string {
// Almost all errors are not interesting. Real errors will be logged.
func (ih *ImageHandler) scaledCached(buf *bytes.Buffer, file blob.Ref) (format string) {
key := cacheKey(file.String(), ih.MaxWidth, ih.MaxHeight)
br, err := ih.thumbMeta.Get(key)
br, err := ih.ThumbMeta.Get(key)
if err == errCacheMiss {
return
}
@ -188,6 +188,9 @@ func (ih *ImageHandler) scaledCached(buf *bytes.Buffer, file blob.Ref) (format s
}
fr, err := ih.cached(br)
if err != nil {
if imageDebug {
log.Printf("Could not get cached image %v: %v\n", br, err)
}
return
}
defer fr.Close()
@ -247,10 +250,10 @@ func (ih *ImageHandler) scaleImage(fileRef blob.Ref) (*formatAndImage, error) {
// images being resized concurrently.
ramSize := int64(conf.Width) * int64(conf.Height) * 3
if err = ih.resizeSem.Acquire(ramSize); err != nil {
if err = ih.ResizeSem.Acquire(ramSize); err != nil {
return nil, err
}
defer ih.resizeSem.Release(ramSize)
defer ih.ResizeSem.Release(ramSize)
i, imConfig, err := images.Decode(sr, &images.DecodeOpts{
MaxWidth: ih.MaxWidth,
@ -325,7 +328,7 @@ func (ih *ImageHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request, fil
var imageData []byte
format := ""
cacheHit := false
if ih.thumbMeta != nil && !disableThumbCache {
if ih.ThumbMeta != nil && !disableThumbCache {
var buf bytes.Buffer
format = ih.scaledCached(&buf, file)
if format != "" {
@ -346,7 +349,7 @@ func (ih *ImageHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request, fil
im := imi.(*formatAndImage)
imageData = im.image
format = im.format
if ih.thumbMeta != nil {
if ih.ThumbMeta != nil {
err := ih.cacheScaled(imageData, key)
if err != nil {
log.Printf("image resize: %v", err)

File diff suppressed because it is too large Load Diff

View File

@ -143,7 +143,7 @@ func (rh *RootHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
return
}
if req.URL.Path == "/favicon.ico" {
serveStaticFile(rw, req, Files, "favicon.ico")
ServeStaticFile(rw, req, Files, "favicon.ico")
return
}
f := func(p string, a ...interface{}) {

View File

@ -27,30 +27,33 @@ import (
const memLRUSize = 1024 // arbitrary
// thumbMeta is a mapping from an image's scaling parameters (encoding
var errCacheMiss = errors.New("not in cache")
// ThumbMeta is a mapping from an image's scaling parameters (encoding
// as an opaque "key" string) and the blobref of the thumbnail
// (currently it's file schema blob)
// (currently its file schema blob).
// ThumbMeta is safe for concurrent use by multiple goroutines.
//
// The key will be some string containing the original full-sized image's
// blobref, its target dimensions, and any possible transformations on
// it (e.g. cropping it to square).
var errCacheMiss = errors.New("not in cache")
type thumbMeta struct {
mem *lru.Cache // string (see key format) -> blob.Ref
type ThumbMeta struct {
mem *lru.Cache // key -> blob.Ref
kv sorted.KeyValue // optional
}
// kv is optional
func newThumbMeta(kv sorted.KeyValue) *thumbMeta {
return &thumbMeta{
// NewThumbMeta returns a new in-memory ThumbMeta, backed with the
// optional kv.
// If kv is nil, key/value pairs are stored in memory only.
func NewThumbMeta(kv sorted.KeyValue) *ThumbMeta {
return &ThumbMeta{
mem: lru.New(memLRUSize),
kv: kv,
}
}
func (m *thumbMeta) Get(key string) (br blob.Ref, err error) {
func (m *ThumbMeta) Get(key string) (blob.Ref, error) {
var br blob.Ref
if v, ok := m.mem.Get(key); ok {
return v.(blob.Ref), nil
}
@ -72,7 +75,7 @@ func (m *thumbMeta) Get(key string) (br blob.Ref, err error) {
return br, errCacheMiss
}
func (m *thumbMeta) Put(key string, br blob.Ref) error {
func (m *ThumbMeta) Put(key string, br blob.Ref) error {
m.mem.Add(key, br)
if m.kv != nil {
return m.kv.Set(key, br.String())

View File

@ -38,6 +38,7 @@ import (
"camlistore.org/pkg/jsonsign/signhandler"
"camlistore.org/pkg/misc/closure"
"camlistore.org/pkg/search"
"camlistore.org/pkg/server/app"
"camlistore.org/pkg/sorted"
"camlistore.org/pkg/syncutil"
uistatic "camlistore.org/server/camlistored/ui"
@ -77,8 +78,7 @@ type UIHandler struct {
// if we start having clients (like phones) that we want to upload
// but don't trust to have private signing keys?
JSONSignRoot string
publishRoots map[string]*PublishHandler
publishRoots map[string]*publishRoot
prefix string // of the UI handler itself
root *RootHandler
@ -90,7 +90,7 @@ type UIHandler struct {
// Limit peak RAM used by concurrent image thumbnail calls.
resizeSem *syncutil.Sem
thumbMeta *thumbMeta // optional thumbnail key->blob.Ref cache
thumbMeta *ThumbMeta // optional thumbnail key->blob.Ref cache
// sourceRoot optionally specifies the path to root of Camlistore's
// source. If empty, the UI files must be compiled in to the
@ -127,7 +127,6 @@ func uiFromConfig(ld blobserver.Loader, conf jsonconfig.Obj) (h http.Handler, er
resizeSem: syncutil.NewSem(int64(conf.OptionalInt("maxResizeBytes",
constants.DefaultMaxResizeMem))),
}
pubRoots := conf.OptionalList("publishRoots")
cachePrefix := conf.OptionalString("cache", "")
scaledImageConf := conf.OptionalObject("scaledImage")
if err = conf.Validate(); err != nil {
@ -141,24 +140,6 @@ func uiFromConfig(ld blobserver.Loader, conf jsonconfig.Obj) (h http.Handler, er
}
}
if os.Getenv("CAMLI_PUBLISH_ENABLED") == "false" {
// Hack for dev server, to simplify its config with devcam server --publish=false.
pubRoots = nil
}
ui.publishRoots = make(map[string]*PublishHandler)
for _, pubRoot := range pubRoots {
h, err := ld.GetHandler(pubRoot)
if err != nil {
return nil, fmt.Errorf("UI handler's publishRoots references invalid %q", pubRoot)
}
pubh, ok := h.(*PublishHandler)
if !ok {
return nil, fmt.Errorf("UI handler's publishRoots references invalid %q; not a PublishHandler", pubRoot)
}
ui.publishRoots[pubRoot] = pubh
}
checkType := func(key string, htype string) {
v := conf.OptionalString(key, "")
if v == "" {
@ -190,7 +171,7 @@ func uiFromConfig(ld blobserver.Loader, conf jsonconfig.Obj) (h http.Handler, er
return nil, fmt.Errorf("UI handler's cache of %q error: %v", cachePrefix, err)
}
ui.Cache = bs
ui.thumbMeta = newThumbMeta(scaledImageKV)
ui.thumbMeta = NewThumbMeta(scaledImageKV)
}
if ui.sourceRoot == "" {
@ -259,6 +240,79 @@ func uiFromConfig(ld blobserver.Loader, conf jsonconfig.Obj) (h http.Handler, er
return ui, nil
}
type publishRoot struct {
Name string
Permanode blob.Ref
Prefix string
}
// InitHandler goes through all the other configured handlers to discover
// the publisher ones, and uses them to populate ui.publishRoots.
func (ui *UIHandler) InitHandler(hl blobserver.FindHandlerByTyper) error {
searchPrefix, _, err := hl.FindHandlerByType("search")
if err != nil {
return errors.New("No search handler configured, which is necessary for the ui handler")
}
var sh *search.Handler
htype, hi := hl.AllHandlers()
if h, ok := hi[searchPrefix]; !ok {
return errors.New("failed to find the \"search\" handler")
} else {
sh = h.(*search.Handler)
}
camliRootQuery := func(camliRoot string) (*search.SearchResult, error) {
return sh.Query(&search.SearchQuery{
Limit: 1,
Constraint: &search.Constraint{
Permanode: &search.PermanodeConstraint{
Attr: "camliRoot",
Value: camliRoot,
},
},
})
}
for prefix, typ := range htype {
if typ != "app" {
continue
}
ah, ok := hi[prefix].(*app.Handler)
if !ok {
panic(fmt.Sprintf("UI: handler for %v has type \"app\" but is not app.Handler", prefix))
}
if ah.ProgramName() != "publisher" {
continue
}
appConfig := ah.AppConfig()
if appConfig == nil {
log.Printf("UI: app handler for %v has no appConfig", prefix)
continue
}
camliRoot, ok := appConfig["camliRoot"].(string)
if !ok {
log.Printf("UI: camliRoot in appConfig is %T, want string, was %T", appConfig["camliRoot"])
continue
}
result, err := camliRootQuery(camliRoot)
if err != nil {
log.Printf("UI: could not find permanode for camliRoot %v: %v", camliRoot, err)
continue
}
if len(result.Blobs) == 0 || !result.Blobs[0].Blob.Valid() {
log.Printf("UI: no valid permanode for camliRoot %v", camliRoot)
continue
}
if ui.publishRoots == nil {
ui.publishRoots = make(map[string]*publishRoot)
}
ui.publishRoots[prefix] = &publishRoot{
Name: camliRoot,
Prefix: prefix,
Permanode: result.Blobs[0].Blob,
}
}
return nil
}
func (ui *UIHandler) makeClosureHandler(root string) (http.Handler, error) {
return makeClosureHandler(root, "ui")
}
@ -415,11 +469,12 @@ func (ui *UIHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
serveDepsJS(rw, req, ui.uiDir)
return
}
serveStaticFile(rw, req, uistatic.Files, file)
ServeStaticFile(rw, req, uistatic.Files, file)
}
}
func serveStaticFile(rw http.ResponseWriter, req *http.Request, root http.FileSystem, file string) {
// ServeStaticFile serves file from the root virtual filesystem.
func ServeStaticFile(rw http.ResponseWriter, req *http.Request, root http.FileSystem, file string) {
f, err := root.Open("/" + file)
if err != nil {
http.NotFound(rw, req)
@ -441,19 +496,13 @@ func serveStaticFile(rw http.ResponseWriter, req *http.Request, root http.FileSy
func (ui *UIHandler) populateDiscoveryMap(m map[string]interface{}) {
pubRoots := map[string]interface{}{}
for key, pubh := range ui.publishRoots {
for _, v := range ui.publishRoots {
m := map[string]interface{}{
"name": pubh.RootName,
"prefix": []string{key},
// TODO: include gpg key id
"name": v.Name,
"prefix": []string{v.Prefix},
"currentPermanode": v.Permanode.String(),
}
if sh, ok := ui.root.SearchHandler(); ok {
pn, err := sh.Index().PermanodeOfSignerAttrValue(sh.Owner(), "camliRoot", pubh.RootName)
if err == nil {
m["currentPermanode"] = pn.String()
}
}
pubRoots[pubh.RootName] = m
pubRoots[v.Name] = m
}
uiDisco := map[string]interface{}{
@ -537,8 +586,8 @@ func (ui *UIHandler) serveThumbnail(rw http.ResponseWriter, req *http.Request) {
Cache: ui.Cache,
MaxWidth: width,
MaxHeight: height,
thumbMeta: ui.thumbMeta,
resizeSem: ui.resizeSem,
ThumbMeta: ui.thumbMeta,
ResizeSem: ui.resizeSem,
}
th.ServeHTTP(rw, req, blobref)
}
@ -597,7 +646,7 @@ func (ui *UIHandler) serveFromDiskOrStatic(rw http.ResponseWriter, req *http.Req
req.URL.Path = "/" + file
disk.ServeHTTP(rw, req)
} else {
serveStaticFile(rw, req, static, file)
ServeStaticFile(rw, req, static, file)
}
}

View File

@ -63,43 +63,34 @@ func addPublishedConfig(prefixes jsonconfig.Obj,
sourceRoot string) ([]string, error) {
var pubPrefixes []string
for k, v := range published {
name := strings.Replace(k, "/", "", -1)
rootName := name + "Root"
if !v.Root.Valid() {
return nil, fmt.Errorf("Invalid or missing \"rootPermanode\" key in configuration for %s.", k)
if v.CamliRoot == "" {
return nil, fmt.Errorf("Missing \"camliRoot\" key in configuration for %s.", k)
}
if v.GoTemplate == "" {
return nil, fmt.Errorf("Missing \"goTemplate\" key in configuration for %s.", k)
}
ob := map[string]interface{}{}
ob["handler"] = "publish"
ob["handler"] = "app"
appConfig := map[string]interface{}{
"camliRoot": v.CamliRoot,
"cacheRoot": v.CacheRoot,
"goTemplate": v.GoTemplate,
}
handlerArgs := map[string]interface{}{
"rootName": rootName,
"blobRoot": "/bs-and-maybe-also-index/",
"searchRoot": "/my-search/",
"cache": "/cache/",
"rootPermanode": []interface{}{"/sighelper/", v.Root.String()},
"program": v.Program,
"appConfig": appConfig,
}
if sourceRoot != "" {
handlerArgs["sourceRoot"] = sourceRoot
if v.BaseURL != "" {
handlerArgs["baseURL"] = v.BaseURL
}
handlerArgs["goTemplate"] = v.GoTemplate
if v.Style != "" {
handlerArgs["css"] = []interface{}{v.Style}
}
if v.Javascript != "" {
handlerArgs["js"] = []interface{}{v.Javascript}
}
// TODO(mpl): we'll probably want to use osutil.CacheDir() if thumbnails.kv
// contains private info? same for some of the other "camli-cache" ones?
thumbsCacheDir := filepath.Join(tempDir(), "camli-cache")
handlerArgs["scaledImage"] = map[string]interface{}{
"type": "kv",
"file": filepath.Join(thumbsCacheDir, name+"-thumbnails.kv"),
}
if err := os.MkdirAll(thumbsCacheDir, 0700); err != nil {
return nil, fmt.Errorf("Could not create cache dir %s: %v", thumbsCacheDir, err)
program := "publisher"
if v.Program != "" {
program = v.Program
}
handlerArgs["program"] = program
ob["handlerArgs"] = handlerArgs
prefixes[k] = ob
pubPrefixes = append(pubPrefixes, k)
@ -111,20 +102,12 @@ func addPublishedConfig(prefixes jsonconfig.Obj,
func addUIConfig(params *configPrefixesParams,
prefixes jsonconfig.Obj,
uiPrefix string,
published []string,
sourceRoot string) {
args := map[string]interface{}{
"jsonSignRoot": "/sighelper/",
"cache": "/cache/",
}
if len(published) > 0 {
var publishedAsList []interface{}
for _, v := range published {
publishedAsList = append(publishedAsList, v)
}
args["publishRoots"] = publishedAsList
}
if sourceRoot != "" {
args["sourceRoot"] = sourceRoot
}
@ -674,19 +657,18 @@ func genLowLevelConfig(conf *serverconfig.Config) (lowLevelConf *Config, err err
}
}
var published []string
if len(conf.Publish) > 0 {
if !runIndex {
return nil, fmt.Errorf("publishing requires an index")
}
published, err = addPublishedConfig(prefixes, conf.Publish, conf.SourceRoot)
_, err = addPublishedConfig(prefixes, conf.Publish, conf.SourceRoot)
if err != nil {
return nil, fmt.Errorf("Could not generate config for published: %v", err)
}
}
if runIndex {
addUIConfig(prefixesParams, prefixes, "/ui/", published, conf.SourceRoot)
addUIConfig(prefixesParams, prefixes, "/ui/", conf.SourceRoot)
}
if conf.MySQL != "" {

View File

@ -60,27 +60,15 @@
}
},
"/music/": {
"handler": "publish",
"handler": "app",
"handlerArgs": {
"blobRoot": "/bs-and-maybe-also-index/",
"cache": "/cache/",
"css": [
"pics.css"
],
"goTemplate": "gallery.html",
"js": [
"pics.js"
],
"rootName": "musicRoot",
"rootPermanode": [
"/sighelper/",
"sha1-999c6aae4ec8245dfe63edc4a2abb407824a4b5a"
],
"scaledImage": {
"file": "/tmp/camli-cache/music-thumbnails.kv",
"type": "kv"
},
"searchRoot": "/my-search/"
"program": "publisher",
"baseURL": "http://localhost:3178/",
"appConfig": {
"camliRoot": "musicRoot",
"goTemplate": "music.html",
"cacheRoot": "/tmp/blobs/cache"
}
}
},
"/my-search/": {
@ -92,27 +80,14 @@
}
},
"/pics/": {
"handler": "publish",
"handler": "app",
"handlerArgs": {
"blobRoot": "/bs-and-maybe-also-index/",
"cache": "/cache/",
"css": [
"pics.css"
],
"goTemplate": "gallery.html",
"js": [
"pics.js"
],
"rootName": "picsRoot",
"rootPermanode": [
"/sighelper/",
"sha1-046c6aae4ec8245dfe63edc4a2abb407824a4b5a"
],
"scaledImage": {
"file": "/tmp/camli-cache/pics-thumbnails.kv",
"type": "kv"
},
"searchRoot": "/my-search/"
"program": "publisher",
"appConfig": {
"camliRoot": "picsRoot",
"goTemplate": "gallery.html",
"cacheRoot": "/tmp/blobs/cache"
}
}
},
"/setup/": {
@ -151,10 +126,6 @@
"handlerArgs": {
"cache": "/cache/",
"jsonSignRoot": "/sighelper/",
"publishRoots": [
"/music/",
"/pics/"
],
"scaledImage": {
"file": "/tmp/blobs/thumbmeta.kv",
"type": "kv"

View File

@ -2,23 +2,22 @@
"listen": "localhost:3179",
"auth": "userpass:camlistore:pass3179",
"blobPath": "/tmp/blobs",
"kvIndexFile": "/path/to/indexkv.db",
"kvIndexFile": "/path/to/indexkv.db",
"identity": "26F5ABDA",
"identitySecretRing": "/path/to/secring",
"ownerName": "Alice",
"shareHandlerPath": "/share/",
"publish": {
"/pics/": {
"rootPermanode": "sha1-046c6aae4ec8245dfe63edc4a2abb407824a4b5a",
"style": "pics.css",
"js": "pics.js",
"goTemplate": "gallery.html"
},
"/music/": {
"rootPermanode": "sha1-999c6aae4ec8245dfe63edc4a2abb407824a4b5a",
"style": "pics.css",
"js": "pics.js",
"goTemplate": "gallery.html"
}
}
"publish": {
"/pics/": {
"camliRoot": "picsRoot",
"cacheRoot": "/tmp/blobs/cache",
"goTemplate": "gallery.html"
},
"/music/": {
"camliRoot": "musicRoot",
"baseURL": "http://localhost:3178/",
"cacheRoot": "/tmp/blobs/cache",
"goTemplate": "music.html"
}
}
}

View File

@ -13,24 +13,15 @@
}
},
"/blog/": {
"handler": "publish",
"handler": "app",
"handlerArgs": {
"blobRoot": "/bs-and-maybe-also-index/",
"cache": "/cache/",
"css": [
"blog-purple.css"
],
"goTemplate": "blog.html",
"rootName": "blogRoot",
"rootPermanode": [
"/sighelper/",
"sha1-2790ec1ec6fd44b9620b21155c8738aa08d4e3a0"
],
"scaledImage": {
"file": "/tmp/camli-cache/blog-thumbnails.kv",
"type": "kv"
},
"searchRoot": "/my-search/"
"program": "publisher",
"baseURL": "http://localhost:3178/",
"appConfig": {
"camliRoot": "blogRoot",
"goTemplate": "blog.html",
"cacheRoot": "/tmp/blobs/cache"
}
}
},
"/bs-and-index/": {
@ -123,9 +114,6 @@
"handlerArgs": {
"cache": "/cache/",
"jsonSignRoot": "/sighelper/",
"publishRoots": [
"/blog/"
],
"scaledImage": {
"file": "/tmp/blobs/thumbmeta.kv",
"type": "kv"

View File

@ -9,9 +9,10 @@
"s3": "",
"publish": {
"/blog/": {
"rootPermanode": "sha1-2790ec1ec6fd44b9620b21155c8738aa08d4e3a0",
"goTemplate": "blog.html",
"style": "blog-purple.css"
"camliRoot": "blogRoot",
"baseURL": "http://localhost:3178/",
"cacheRoot": "/tmp/blobs/cache",
"goTemplate": "blog.html"
}
},
"replicateTo": [],

View File

@ -67,27 +67,15 @@
}
},
"/pics/": {
"handler": "publish",
"handler": "app",
"handlerArgs": {
"blobRoot": "/bs-and-maybe-also-index/",
"cache": "/cache/",
"css": [
"pics.css"
],
"goTemplate": "gallery.html",
"js": [
"pics.js"
],
"rootName": "picsRoot",
"rootPermanode": [
"/sighelper/",
"sha1-2790ec1ec6fd44b9620b21155c8738aa08d4e3a0"
],
"scaledImage": {
"file": "/tmp/camli-cache/pics-thumbnails.kv",
"type": "kv"
},
"searchRoot": "/my-search/"
"program": "publisher",
"baseURL": "http://localhost:3178/",
"appConfig": {
"camliRoot": "picsRoot",
"goTemplate": "gallery.html",
"cacheRoot": "/tmp/blobs/cache"
}
}
},
"/setup/": {
@ -126,9 +114,6 @@
"handlerArgs": {
"cache": "/cache/",
"jsonSignRoot": "/sighelper/",
"publishRoots": [
"/pics/"
],
"scaledImage": {
"file": "/tmp/blobs/thumbmeta.kv",
"type": "kv"

View File

@ -9,10 +9,10 @@
"s3": "",
"publish": {
"/pics/": {
"rootPermanode": "sha1-2790ec1ec6fd44b9620b21155c8738aa08d4e3a0",
"goTemplate": "gallery.html",
"js": "pics.js",
"style": "pics.css"
"camliRoot": "picsRoot",
"baseURL": "http://localhost:3178/",
"cacheRoot": "/tmp/blobs/cache",
"goTemplate": "gallery.html"
}
},
"replicateTo": [],

View File

@ -87,6 +87,9 @@ func (cl ClaimsByDate) String() string {
type FileInfo struct {
FileName string `json:"fileName"`
// TODO(mpl): I've noticed that Size is actually set to the
// number of entries in the dir. fix the doc or the behaviour?
// Size is the size of files. It is not set for directories.
Size int64 `json:"size"`

View File

@ -18,7 +18,6 @@ limitations under the License.
package serverconfig
import (
"camlistore.org/pkg/blob"
"camlistore.org/pkg/types"
)
@ -70,14 +69,27 @@ type Config struct {
Picasa string `json:"picasa,omitempty"` // picasa importer.
}
// Publish holds the server configuration values specific to publishing, i.e. to a publish handler.
// Publish holds the server configuration values specific to a publisher, i.e. to a publish prefix.
type Publish struct {
// Root is the permanode used as the root for all the paths served by this publish handler. The camliRoot value that is the root path for this handler is a property of this permanode.
Root blob.Ref `json:"rootPermanode"`
// GoTemplate is the name of the Go template file used by this publish handler to represent the data. This file should live in server/camlistored/ui/.
// Program is the server app program to run as the publisher.
// Defaults to "publisher".
Program string `json:"program"`
// CamliRoot value that defines our root permanode for this
// publisher. The root permanode is used as the root for all the
// paths served by this publisher.
CamliRoot string `json:"camliRoot"`
// Base URL the app will run at.
BaseURL string `json:"baseURL,omitempty"`
// GoTemplate is the name of the Go template file used by this
// publisher to represent the data. This file should live in
// app/publisher/.
GoTemplate string `json:"goTemplate"`
// Javascript is the name of an optional javascript file used for additional features. This file should live in server/camlistored/ui/.
Javascript string `json:"js,omitempty"`
// Style is the name of an optional css file. This file should live in server/camlistored/ui/.
Style string `json:"style,omitempty"`
// CacheRoot is the path that will be used as the root for the
// caching blobserver (for images). No caching if empty.
// An example value is Config.BlobPath + "/cache".
CacheRoot string `json:"cacheRoot,omitempty"`
}

View File

@ -1,150 +0,0 @@
/*
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.
*/
goog.provide('cam.GalleryPage');
goog.require('goog.dom');
goog.require('goog.events.EventHandler');
goog.require('goog.events.EventType');
goog.require('goog.ui.Component');
goog.require('cam.ServerConnection');
// @param {cam.ServerType.DiscoveryDocument} config Global config of the current server this page is being rendered for.
// @param {goog.dom.DomHelper=} opt_domHelper DOM helper to use.
cam.GalleryPage = function(config, opt_domHelper) {
goog.base(this, opt_domHelper);
this.config_ = config;
this.connection_ = new cam.ServerConnection(config);
};
goog.inherits(cam.GalleryPage, goog.ui.Component);
cam.GalleryPage.prototype.decorateInternal = function(element) {
cam.GalleryPage.superClass_.decorateInternal.call(this, element);
};
cam.GalleryPage.prototype.disposeInternal = function() {
cam.GalleryPage.superClass_.disposeInternal.call(this);
};
cam.GalleryPage.prototype.enterDocument = function() {
cam.GalleryPage.superClass_.enterDocument.call(this);
var members = goog.dom.getElement('members');
if (!members) {
return;
}
var children = goog.dom.getChildren(members);
if (!children || children.length < 1) {
return;
}
goog.array.forEach(children, function(li) {
li.src = li.src + '&square=1';
})
if (camliViewIsOwner) {
var el = this.getElement();
goog.dom.classes.add(el, 'camliadmin');
goog.array.forEach(children, function(li) {
var lichild = goog.dom.getFirstElementChild(li);
var titleSpan = goog.dom.getLastElementChild(lichild);
var editLink = goog.dom.createElement('a', {'href': '#'});
goog.dom.classes.add(editLink, 'hidden');
goog.dom.setTextContent(editLink, 'edit title');
var titleInput = goog.dom.createElement('input');
goog.dom.classes.add(titleInput, 'hidden');
goog.events.listen(editLink,
goog.events.EventType.CLICK,
function(e) {
goog.dom.classes.remove(titleSpan, 'visible');
goog.dom.classes.add(titleSpan, 'hidden');
goog.dom.classes.remove(titleInput, 'hidden');
goog.dom.classes.add(titleInput, 'visible');
titleInput.focus();
titleInput.select();
e.stopPropagation();
e.preventDefault();
},
false, this
);
goog.events.listen(li,
goog.events.EventType.MOUSEOVER,
function(e) {
goog.dom.classes.remove(editLink, 'hidden');
goog.dom.classes.add(editLink, 'title-edit');
},
false, this
);
goog.events.listen(li,
goog.events.EventType.MOUSEOUT,
function(e) {
goog.dom.classes.remove(editLink, 'title-edit');
goog.dom.classes.add(editLink, 'hidden');
goog.dom.classes.remove(titleInput, 'visible');
goog.dom.classes.add(titleInput, 'hidden');
goog.dom.classes.remove(titleSpan, 'hidden');
goog.dom.classes.add(titleSpan, 'visible');
},
false, this
);
goog.events.listen(titleInput,
goog.events.EventType.KEYPRESS,
goog.bind(function(e) {
if (e.keyCode == 13) {
this.saveImgTitle_(titleInput, titleSpan);
}
}, this),
false, this
);
goog.dom.insertSiblingBefore(editLink, titleSpan);
goog.dom.insertChildAt(li, titleInput, 1);
}, this
)
}
}
// @param {string} titleInput text field element for title
// @param {string} titleSpan span element containing the title
cam.GalleryPage.prototype.saveImgTitle_ = function (titleInput, titleSpan) {
var spanText = goog.dom.getTextContent(titleSpan);
var newVal = titleInput.value;
if (newVal != "" && newVal != spanText) {
goog.dom.setTextContent(titleSpan, newVal);
var blobRef = goog.dom.getParentElement(titleInput).id.replace(/^camli-/, '');
this.connection_.newSetAttributeClaim(
blobRef,
"title",
newVal,
function() {
},
function(msg) {
alert(msg);
}
);
}
goog.dom.classes.remove(titleInput, 'visible');
goog.dom.classes.add(titleInput, 'hidden');
goog.dom.classes.remove(titleSpan, 'hidden');
goog.dom.classes.add(titleSpan, 'visible');
}
cam.GalleryPage.prototype.exitDocument = function() {
cam.GalleryPage.superClass_.exitDocument.call(this);
};

View File

@ -72,14 +72,14 @@ config, as used by <code>devcam server</code>.</p>
<pre>
"publish": {
"/pics/": {
"rootPermanode": "sha1-6cbe9e1c35e854eab028cba43d099d35ceae0de8",
"style": "pics.css",
"js": "pics.js",
"camliRoot": "mypics",
"baseURL": "http://localhost:3178/",
"cacheRoot": "/home/joe/var/camlistore/blobs/cache",
"goTemplate": "gallery.html"
}
}
</pre>
<p>One can create any permanode with camput or the UI and use it as the rootPermanode.</p>
<p>One can create any permanode with camput or the UI, and set its camliRoot attribute to the value set in the config, to use it as the root permanode for publishing.</p>
<p>Please see the <a href="/gw/doc/publishing/README"">publishing README</a> if you want to make/contribute more publishing views.</p>