blob: ea196b941acd8653dca5b19dbf3ebe103000caff [file] [log] [blame]
// Copyright 2017 The Upspin Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Command browser presents a web interface to the Upspin name space.
// It operates as the user in the specified config.
// It is still in its early stages of development and should be used with care.
package main // import "exp.upspin.io/cmd/browser"
// TODO(adg): Flesh out the inspector (show blocks, etc).
// TODO(adg): Drag and drop support.
// TODO(adg): Secure the web UI; only allow the local user to access it.
// TODO(adg): Update the URL in the browser window to reflect the UI.
// TODO(adg): Facility to add/edit Access files in UI.
// TODO(adg): Awareness of Access files during copy and remove.
// TODO(adg): Show progress of removes/copies in the user interface.
import (
"crypto/rand"
"encoding/json"
"flag"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"path"
"runtime"
"strings"
"sync"
"time"
"exp.upspin.io/cmd/browser/static"
"golang.org/x/net/xsrftoken"
"upspin.io/errors"
"upspin.io/flags"
"upspin.io/upspin"
_ "upspin.io/transports"
)
func main() {
httpAddr := flag.String("http", "localhost:8000", "HTTP listen `address` (must be loopback)")
flags.Parse(flags.Client)
// Disallow listening on non-loopback addresses until we have a better
// security model. (Even this is not really secure enough.)
if err := isLocal(*httpAddr); err != nil {
exit(err)
}
s, err := newServer()
if err != nil {
exit(err)
}
http.Handle("/", s)
l, err := net.Listen("tcp", *httpAddr)
if err != nil {
exit(err)
}
url := fmt.Sprintf("http://%s/#key=%s", *httpAddr, s.key)
if !startBrowser(url) {
fmt.Printf("Open %s in your web browser.\n", url)
} else {
fmt.Printf("Serving at %s\n", url)
}
exit(http.Serve(l, nil))
}
func exit(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
// server implements an http.Handler that performs various Upspin operations
// using a config. It is the back end for the JavaScript Upspin browser.
type server struct {
// key to prevent request forgery; static for server's lifetime.
key string
mu sync.Mutex
cfg upspin.Config // Non-nil if signup flow has been completed.
cli upspin.Client
}
func newServer() (*server, error) {
key, err := generateKey()
if err != nil {
return nil, err
}
return &server{
key: key,
}, nil
}
func (s *server) hasConfig() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.cfg != nil && s.cli != nil
}
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
if p == "/_upspin" {
s.serveAPI(w, r)
return
}
if strings.Contains(p, "@") {
s.serveContent(w, r)
return
}
s.serveStatic(w, r)
}
func (s *server) serveStatic(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path[1:]
if p == "" {
p = "index.html"
}
b, err := static.File(p)
if errors.Match(errors.E(errors.NotExist), err) {
http.NotFound(w, r)
return
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.ServeContent(w, r, path.Base(p), time.Now(), strings.NewReader(b))
}
func (s *server) serveContent(w http.ResponseWriter, r *http.Request) {
if !s.hasConfig() {
http.Error(w, "No configuration", http.StatusServiceUnavailable)
return
}
p := r.URL.Path[1:]
if !xsrftoken.Valid(r.FormValue("token"), s.key, string(s.cfg.UserName()), p) {
http.Error(w, "Invalid XSRF token", http.StatusForbidden)
return
}
name := upspin.PathName(p)
de, err := s.cli.Lookup(name, true)
if err != nil {
httpError(w, err)
return
}
f, err := s.cli.Open(name)
if err != nil {
httpError(w, err)
return
}
http.ServeContent(w, r, path.Base(p), de.Time.Go(), f)
f.Close()
}
func (s *server) serveAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Expected POST request", http.StatusMethodNotAllowed)
return
}
// Require a valid key.
if r.FormValue("key") != s.key {
http.Error(w, "Invalid key", http.StatusForbidden)
return
}
method := r.FormValue("method")
// Don't permit accesses of non-startup methods if there is no config
// nor client; those methods need them.
if method != "startup" && !s.hasConfig() {
http.Error(w, "No configuration", http.StatusServiceUnavailable)
return
}
var resp interface{}
switch method {
case "startup":
sResp, cfg, err := s.startup(r)
var errString string
if err != nil {
errString = err.Error()
}
var user string
if cfg != nil {
user = string(cfg.UserName())
}
resp = struct {
Startup *startupResponse
UserName string
Error string
}{sResp, user, errString}
case "list":
path := upspin.PathName(r.FormValue("path"))
des, err := s.cli.Glob(upspin.AllFilesGlob(path))
var errString string
if err != nil {
errString = err.Error()
}
var entries []entryWithToken
for _, de := range des {
tok := xsrftoken.Generate(s.key, string(s.cfg.UserName()), string(de.Name))
entries = append(entries, entryWithToken{
DirEntry: de,
FileToken: tok,
})
}
resp = struct {
Entries []entryWithToken
Error string
}{entries, errString}
case "mkdir":
_, err := s.cli.MakeDirectory(upspin.PathName(r.FormValue("path")))
var errString string
if err != nil {
errString = err.Error()
}
resp = struct {
Error string
}{errString}
case "rm":
var errString string
for _, p := range r.Form["paths[]"] {
if err := s.rm(upspin.PathName(p)); err != nil {
errString = err.Error()
break
}
}
resp = struct {
Error string
}{errString}
case "copy":
dst := upspin.PathName(r.FormValue("dest"))
var paths []upspin.PathName
for _, p := range r.Form["paths[]"] {
paths = append(paths, upspin.PathName(p))
}
var errString string
if err := s.copy(dst, paths); err != nil {
errString = err.Error()
}
resp = struct {
Error string
}{errString}
}
b, err := json.Marshal(resp)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write(b)
}
type entryWithToken struct {
*upspin.DirEntry
FileToken string
}
func generateKey() (string, error) {
b := make([]byte, 32)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", b), nil
}
// isLocal returns an error if the given address is not a loopback address.
func isLocal(addr string) error {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return err
}
ips, err := net.LookupIP(host)
if err != nil {
return err
}
for _, ip := range ips {
if !ip.IsLoopback() {
return fmt.Errorf("cannot listen on non-loopback address %q", addr)
}
}
return nil
}
// ifError checks if the error is the expected one, and if so writes back an
// HTTP error of the corresponding code.
func ifError(w http.ResponseWriter, got error, want errors.Kind, code int) bool {
if !errors.Match(errors.E(want), got) {
return false
}
http.Error(w, http.StatusText(code), code)
return true
}
func httpError(w http.ResponseWriter, err error) {
// This construction sets the HTTP error to the first type that matches.
switch {
case ifError(w, err, errors.Private, http.StatusForbidden):
case ifError(w, err, errors.Permission, http.StatusForbidden):
case ifError(w, err, errors.NotExist, http.StatusNotFound):
case ifError(w, err, errors.BrokenLink, http.StatusNotFound):
default:
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// startBrowser tries to open the URL in a web browser,
// and reports whether it succeed.
func startBrowser(url string) bool {
var args []string
switch runtime.GOOS {
case "darwin":
args = []string{"open"}
case "windows":
args = []string{"cmd", "/c", "start"}
default:
args = []string{"xdg-open"}
}
cmd := exec.Command(args[0], append(args[1:], url)...)
return cmd.Start() == nil
}