1
// Copyright 2016 Canonical Ltd.
2
// Licensed under the AGPLv3, see LICENCE file for details.
21
"github.com/bmizerany/pat"
22
"github.com/juju/errors"
24
agenttools "github.com/juju/juju/agent/tools"
25
"github.com/juju/juju/state/binarystorage"
26
"github.com/juju/juju/version"
30
jsMimeType = mime.TypeByExtension(".js")
31
spritePath = filepath.FromSlash("static/gui/build/app/assets/stack/svg/sprite.css.svg")
34
// guiRouter serves the Juju GUI routes.
35
// Serving the Juju GUI is done with the following assumptions:
36
// - the archive is compressed in tar.bz2 format;
37
// - the archive includes a top directory named "jujugui-{version}" where
38
// version is semver (like "2.0.1"). This directory includes another
39
// "jujugui" directory where the actual Juju GUI files live;
40
// - the "jujugui" directory includes a "static" subdirectory with the Juju
41
// GUI assets to be served statically;
42
// - the "jujugui" directory specifically includes a
43
// "static/gui/build/app/assets/stack/svg/sprite.css.svg" file, which is
44
// required to render the Juju GUI index file;
45
// - the "jujugui" directory includes a "templates/index.html.go" file which is
46
// used to render the Juju GUI index. The template receives at least the
47
// following variables in its context: "comboURL", "configURL", "debug"
48
// and "spriteContent". It might receive more variables but cannot assume
49
// them to be always provided;
50
// - the "jujugui" directory includes a "templates/config.js.go" file which is
51
// used to render the Juju GUI configuration file. The template receives at
52
// least the following variables in its context: "base", "host", "socket",
53
// "uuid" and "version". It might receive more variables but cannot assume
54
// them to be always provided.
55
type guiRouter struct {
61
// handleGUI adds the Juju GUI routes to the given serve mux.
62
// The given pattern is used as base URL path, and is assumed to include
63
// ":modeluuid" and a trailing slash.
64
func handleGUI(mux *pat.PatternServeMux, pattern string, dataDir string, ctxt httpContext) {
70
guiHandleAll := func(pattern string, h func(*guiHandler, http.ResponseWriter, *http.Request)) {
71
handleAll(mux, pattern, gr.ensureFileHandler(h))
73
hashedPattern := pattern + ":hash"
74
guiHandleAll(hashedPattern+"/config.js", (*guiHandler).serveConfig)
75
guiHandleAll(hashedPattern+"/combo", (*guiHandler).serveCombo)
76
guiHandleAll(hashedPattern+"/static/", (*guiHandler).serveStatic)
77
// The index is served when all remaining URLs are requested, so that
78
// the single page JavaScript application can properly handles its routes.
79
guiHandleAll(pattern, (*guiHandler).serveIndex)
82
// ensureFileHandler decorates the given function to ensure the Juju GUI files
83
// are available on disk.
84
func (gr *guiRouter) ensureFileHandler(h func(gh *guiHandler, w http.ResponseWriter, req *http.Request)) http.Handler {
85
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
86
rootDir, hash, err := gr.ensureFiles(req)
88
// Note that ensureFiles also checks that the model UUID is valid.
92
qhash := req.URL.Query().Get(":hash")
93
if qhash != "" && qhash != hash {
94
sendError(w, errors.NotFoundf("resource with %q hash", qhash))
97
uuid := req.URL.Query().Get(":modeluuid")
100
baseURLPath: strings.Replace(gr.pattern, ":modeluuid", uuid, -1),
108
// ensureFiles checks that the GUI files are available on disk.
109
// If they are not, it means this is the first time this Juju GUI version is
110
// accessed. In this case, retrieve the Juju GUI archive from the storage and
111
// uncompress it to disk. This function returns the current GUI root directory
113
func (gr *guiRouter) ensureFiles(req *http.Request) (rootDir string, hash string, err error) {
114
// Retrieve the Juju GUI info from the GUI storage.
115
st, err := gr.ctxt.stateForRequestUnauthenticated(req)
117
return "", "", errors.Annotate(err, "cannot open state")
119
storage, err := st.GUIStorage()
121
return "", "", errors.Annotate(err, "cannot open GUI storage")
123
defer storage.Close()
124
vers, hash, err := guiVersionAndHash(storage)
126
return "", "", errors.Trace(err)
128
logger.Debugf("serving Juju GUI version %s", vers)
130
// Check if the current Juju GUI archive has been already expanded on disk.
131
baseDir := agenttools.SharedGUIDir(gr.dataDir)
132
// Note that we include the hash in the root directory so that when the GUI
133
// archive changes we can be sure that clients will not use files from
135
rootDir = filepath.Join(baseDir, hash)
136
info, err := os.Stat(rootDir)
139
return rootDir, hash, nil
141
return "", "", errors.Errorf("cannot use Juju GUI root directory %q: not a directory", rootDir)
143
if !os.IsNotExist(err) {
144
return "", "", errors.Annotate(err, "cannot stat Juju GUI root directory")
147
// Fetch the Juju GUI archive from the GUI storage and expand it.
148
_, r, err := storage.Open(vers)
150
return "", "", errors.Annotatef(err, "cannot find GUI archive version %q", vers)
153
if err := os.MkdirAll(baseDir, 0755); err != nil {
154
return "", "", errors.Annotate(err, "cannot create Juju GUI base directory")
156
guiDir := "jujugui-" + vers + "/jujugui"
157
if err := uncompressGUI(r, guiDir, rootDir); err != nil {
158
return "", "", errors.Annotate(err, "cannot uncompress Juju GUI archive")
160
return rootDir, hash, nil
163
// guiVersionAndHash returns the version and the SHA256 hash of the current
165
func guiVersionAndHash(storage binarystorage.Storage) (vers, hash string, err error) {
166
// TODO frankban: retrieve current GUI version from somewhere.
167
// For now, just return an arbitrary version from the storage.
168
allMeta, err := storage.AllMetadata()
170
return "", "", errors.Annotate(err, "cannot retrieve GUI metadata")
172
if len(allMeta) == 0 {
173
return "", "", errors.NotFoundf("Juju GUI")
175
return allMeta[0].Version, allMeta[0].SHA256, nil
178
// uncompressGUI uncompresses the tar.bz2 Juju GUI archive provided in r.
179
// The sourceDir directory included in the tar archive is copied to targetDir.
180
func uncompressGUI(r io.Reader, sourceDir, targetDir string) error {
181
tempDir, err := ioutil.TempDir("", "gui")
183
return errors.Annotate(err, "cannot create Juju GUI temporary directory")
185
defer os.Remove(tempDir)
186
tr := tar.NewReader(bzip2.NewReader(r))
188
hdr, err := tr.Next()
193
return errors.Annotate(err, "cannot parse archive")
195
if hdr.Name != sourceDir && !strings.HasPrefix(hdr.Name, sourceDir+"/") {
198
path := filepath.Join(tempDir, hdr.Name)
199
info := hdr.FileInfo()
201
if err := os.MkdirAll(path, info.Mode()); err != nil {
202
return errors.Annotate(err, "cannot create directory")
206
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode())
208
return errors.Annotate(err, "cannot open file")
211
if _, err := io.Copy(f, tr); err != nil {
212
return errors.Annotate(err, "cannot copy file content")
215
if err := os.Rename(filepath.Join(tempDir, sourceDir), targetDir); err != nil {
216
return errors.Annotate(err, "cannot rename Juju GUI root directory")
221
// guiHandler serves the Juju GUI.
222
type guiHandler struct {
229
// serveStatic serves the GUI static files.
230
func (h *guiHandler) serveStatic(w http.ResponseWriter, req *http.Request) {
231
staticDir := filepath.Join(h.rootDir, "static")
232
fs := http.FileServer(http.Dir(staticDir))
233
http.StripPrefix(h.hashedPath("static/"), fs).ServeHTTP(w, req)
236
// serveCombo serves the GUI JavaScript and CSS files, dynamically combined.
237
func (h *guiHandler) serveCombo(w http.ResponseWriter, req *http.Request) {
239
// The combo query is like /combo/?path/to/file1&path/to/file2 ...
240
parts := strings.Split(req.URL.RawQuery, "&")
241
paths := make([]string, 0, len(parts))
242
for _, p := range parts {
243
fpath, err := getGUIComboPath(h.rootDir, p)
245
sendError(w, errors.Annotate(err, "cannot combine files"))
251
paths = append(paths, fpath)
252
// Assume the Juju GUI does not mix different content types when
253
// combining contents.
255
ctype = mime.TypeByExtension(filepath.Ext(fpath))
258
w.Header().Set("Content-Type", ctype)
259
for _, fpath := range paths {
260
sendGUIComboFile(w, fpath)
264
func getGUIComboPath(rootDir, query string) (string, error) {
265
k := strings.SplitN(query, "=", 2)[0]
266
fname, err := url.QueryUnescape(k)
268
return "", errors.NewBadRequest(err, fmt.Sprintf("invalid file name %q", k))
270
// Ignore pat injected queries.
271
if strings.HasPrefix(fname, ":") {
274
// The Juju GUI references its combined files starting from the
275
// "static/gui/build" directory.
276
fname = filepath.Clean(fname)
277
if fname == ".." || strings.HasPrefix(fname, "../") {
278
return "", errors.BadRequestf("forbidden file path %q", k)
280
return filepath.Join(rootDir, "static", "gui", "build", fname), nil
283
func sendGUIComboFile(w io.Writer, fpath string) {
284
f, err := os.Open(fpath)
286
logger.Infof("cannot send combo file %q: %s", fpath, err)
290
if _, err := io.Copy(w, f); err != nil {
293
fmt.Fprintf(w, "\n/* %s */\n", filepath.Base(fpath))
296
// serveIndex serves the GUI index file.
297
func (h *guiHandler) serveIndex(w http.ResponseWriter, req *http.Request) {
298
spriteFile := filepath.Join(h.rootDir, spritePath)
299
spriteContent, err := ioutil.ReadFile(spriteFile)
301
sendError(w, errors.Annotate(err, "cannot read sprite file"))
304
tmpl := filepath.Join(h.rootDir, "templates", "index.html.go")
305
renderGUITemplate(w, tmpl, map[string]interface{}{
306
"comboURL": h.hashedPath("combo"),
307
"configURL": h.hashedPath("config.js"),
308
// TODO frankban: make it possible to enable debug.
310
"spriteContent": string(spriteContent),
314
// serveConfig serves the Juju GUI JavaScript configuration file.
315
func (h *guiHandler) serveConfig(w http.ResponseWriter, req *http.Request) {
316
w.Header().Set("Content-Type", jsMimeType)
317
tmpl := filepath.Join(h.rootDir, "templates", "config.js.go")
318
renderGUITemplate(w, tmpl, map[string]interface{}{
319
"base": h.baseURLPath,
321
"socket": "/model/$uuid/api",
323
"version": version.Current.String(),
327
// hashedPath returns the gull path (including the GUI archive hash) to the
328
// given path, that must not start with a slash.
329
func (h *guiHandler) hashedPath(p string) string {
330
return path.Join(h.baseURLPath, h.hash, p)
333
func renderGUITemplate(w http.ResponseWriter, tmpl string, ctx map[string]interface{}) {
334
// TODO frankban: cache parsed template.
335
t, err := template.ParseFiles(tmpl)
337
sendError(w, errors.Annotate(err, "cannot parse template"))
340
if err := t.Execute(w, ctx); err != nil {
341
sendError(w, errors.Annotate(err, "cannot render template"))