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