diff --git a/api/rest.go b/api/rest.go index dedb540..018d25a 100644 --- a/api/rest.go +++ b/api/rest.go @@ -9,7 +9,6 @@ import ( "net/http" "os" "path/filepath" - "time" "github.com/ClusterCockpit/cc-jobarchive/config" "github.com/ClusterCockpit/cc-jobarchive/graph" @@ -28,15 +27,18 @@ type RestApi struct { } func (api *RestApi) MountRoutes(r *mux.Router) { - r.HandleFunc("/api/jobs/start_job/", api.startJob).Methods(http.MethodPost, http.MethodPut) - r.HandleFunc("/api/jobs/stop_job/", api.stopJob).Methods(http.MethodPost, http.MethodPut) - r.HandleFunc("/api/jobs/stop_job/{id}", api.stopJob).Methods(http.MethodPost, http.MethodPut) + r = r.PathPrefix("/api").Subrouter() + r.StrictSlash(true) - r.HandleFunc("/api/jobs/{id}", api.getJob).Methods(http.MethodGet) - r.HandleFunc("/api/jobs/tag_job/{id}", api.tagJob).Methods(http.MethodPost, http.MethodPatch) + r.HandleFunc("/jobs/start_job/", api.startJob).Methods(http.MethodPost, http.MethodPut) + r.HandleFunc("/jobs/stop_job/", api.stopJob).Methods(http.MethodPost, http.MethodPut) + r.HandleFunc("/jobs/stop_job/{id}", api.stopJob).Methods(http.MethodPost, http.MethodPut) - r.HandleFunc("/api/machine_state/{cluster}/{host}", api.getMachineState).Methods(http.MethodGet) - r.HandleFunc("/api/machine_state/{cluster}/{host}", api.putMachineState).Methods(http.MethodPut, http.MethodPost) + r.HandleFunc("/jobs/{id}", api.getJob).Methods(http.MethodGet) + r.HandleFunc("/jobs/tag_job/{id}", api.tagJob).Methods(http.MethodPost, http.MethodPatch) + + r.HandleFunc("/machine_state/{cluster}/{host}", api.getMachineState).Methods(http.MethodGet) + r.HandleFunc("/machine_state/{cluster}/{host}", api.putMachineState).Methods(http.MethodPut, http.MethodPost) } type StartJobApiRespone struct { @@ -158,17 +160,18 @@ func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) { return } - job := schema.Job{ - BaseJob: req.BaseJob, - StartTime: time.Unix(req.StartTime, 0), - } - - job.RawResources, err = json.Marshal(req.Resources) + req.RawResources, err = json.Marshal(req.Resources) if err != nil { log.Fatal(err) } - res, err := api.DB.NamedExec(schema.JobInsertStmt, job) + res, err := api.DB.NamedExec(`INSERT INTO job ( + job_id, user, project, cluster, partition, array_job_id, num_nodes, num_hwthreads, num_acc, + exclusive, monitoring_status, smt, job_state, start_time, duration, resources, meta_data + ) VALUES ( + :job_id, :user, :project, :cluster, :partition, :array_job_id, :num_nodes, :num_hwthreads, :num_acc, + :exclusive, :monitoring_status, :smt, :job_state, :start_time, :duration, :resources, :meta_data + );`, req) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return diff --git a/auth/auth.go b/auth/auth.go index d10ac3b..e463fd0 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -23,12 +23,13 @@ import ( ) type User struct { - Username string - Password string - Name string - IsAdmin bool - ViaLdap bool - Email string + Username string + Password string + Name string + IsAdmin bool + IsAPIUser bool + ViaLdap bool + Email string } type ContextKey string @@ -110,6 +111,9 @@ func AddUserToDB(db *sqlx.DB, arg string) error { if parts[1] == "admin" { roles = "[\"ROLE_ADMIN\"]" } + if parts[1] == "api" { + roles = "[\"ROLE_API\"]" + } _, err = sq.Insert("user").Columns("username", "password", "roles").Values(parts[0], string(password), roles).RunWith(db).Exec() if err != nil { @@ -124,7 +128,7 @@ func DelUserFromDB(db *sqlx.DB, username string) error { return err } -func fetchUserFromDB(db *sqlx.DB, username string) (*User, error) { +func FetchUserFromDB(db *sqlx.DB, username string) (*User, error) { user := &User{Username: username} var hashedPassword, name, rawRoles, email sql.NullString if err := sq.Select("password", "ldap", "name", "roles", "email").From("user"). @@ -141,8 +145,11 @@ func fetchUserFromDB(db *sqlx.DB, username string) (*User, error) { json.Unmarshal([]byte(rawRoles.String), &roles) } for _, role := range roles { - if role == "ROLE_ADMIN" { + switch role { + case "ROLE_ADMIN": user.IsAdmin = true + case "ROLE_API": + user.IsAPIUser = true } } @@ -154,7 +161,7 @@ func fetchUserFromDB(db *sqlx.DB, username string) (*User, error) { func Login(db *sqlx.DB) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { username, password := r.FormValue("username"), r.FormValue("password") - user, err := fetchUserFromDB(db, username) + user, err := FetchUserFromDB(db, username) if err == nil && user.ViaLdap && ldapAuthEnabled { err = loginViaLdap(user, password) } else if err == nil && !user.ViaLdap && user.Password != "" { @@ -168,7 +175,7 @@ func Login(db *sqlx.DB) http.Handler { if err != nil { log.Printf("login failed: %s\n", err.Error()) rw.WriteHeader(http.StatusUnauthorized) - templates.Render(rw, r, "login", &templates.Page{ + templates.Render(rw, r, "login.html", &templates.Page{ Title: "Login failed", Login: &templates.LoginPage{ Error: "Username or password incorrect", @@ -231,9 +238,11 @@ func authViaToken(r *http.Request) (*User, error) { claims := token.Claims.(jwt.MapClaims) sub, _ := claims["sub"].(string) isAdmin, _ := claims["is_admin"].(bool) + isAPIUser, _ := claims["is_api"].(bool) return &User{ - Username: sub, - IsAdmin: isAdmin, + Username: sub, + IsAdmin: isAdmin, + IsAPIUser: isAPIUser, }, nil } @@ -264,7 +273,7 @@ func Auth(next http.Handler) http.Handler { log.Printf("authentication failed: no session or jwt found\n") rw.WriteHeader(http.StatusUnauthorized) - templates.Render(rw, r, "login", &templates.Page{ + templates.Render(rw, r, "login.html", &templates.Page{ Title: "Authentication failed", Login: &templates.LoginPage{ Error: "No valid session or JWT provided", @@ -290,6 +299,7 @@ func ProvideJWT(user *User) (string, error) { tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.MapClaims{ "sub": user.Username, "is_admin": user.IsAdmin, + "is_api": user.IsAPIUser, }) return tok.SignedString(JwtPrivateKey) @@ -320,7 +330,7 @@ func Logout(rw http.ResponseWriter, r *http.Request) { } } - templates.Render(rw, r, "login", &templates.Page{ + templates.Render(rw, r, "login.html", &templates.Page{ Title: "Logout successful", Login: &templates.LoginPage{ Info: "Logout successful", diff --git a/go.sum b/go.sum index 56bb41b..88d0d20 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,7 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -68,14 +69,17 @@ github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047 h1:zCoDWFD5 github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74= github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/vfsgen v0.0.0-20180121065927-ffb13db8def0/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -84,6 +88,7 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k= github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U= github.com/vektah/gqlparser/v2 v2.1.0 h1:uiKJ+T5HMGGQM2kRKQ8Pxw8+Zq9qhhZhz/lieYvCMns= @@ -112,6 +117,7 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589 h1:rjUrONFu4kLchcZTfp3/96bR8bW8dIa8uz3cR5n0cgM= golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/server.go b/server.go index 9f76380..00e5c96 100644 --- a/server.go +++ b/server.go @@ -106,13 +106,14 @@ var programConfig ProgramConfig = ProgramConfig{ func main() { var flagReinitDB, flagStopImmediately, flagSyncLDAP bool var flagConfigFile string - var flagNewUser, flagDelUser string + var flagNewUser, flagDelUser, flagGenJWT string flag.BoolVar(&flagReinitDB, "init-db", false, "Go through job-archive and re-initialize `job`, `tag`, and `jobtag` tables") flag.BoolVar(&flagSyncLDAP, "sync-ldap", false, "Sync the `user` table with ldap") flag.BoolVar(&flagStopImmediately, "no-server", false, "Do not start a server, stop right after initialization and argument handling") flag.StringVar(&flagConfigFile, "config", "", "Location of the config file for this server (overwrites the defaults)") - flag.StringVar(&flagNewUser, "add-user", "", "Add a new user. Argument format: `:[admin]:`") + flag.StringVar(&flagNewUser, "add-user", "", "Add a new user. Argument format: `:[admin|api]:`") flag.StringVar(&flagDelUser, "del-user", "", "Remove user by username") + flag.StringVar(&flagGenJWT, "jwt", "", "Generate and print a JWT for the user specified by the username") flag.Parse() if flagConfigFile != "" { @@ -156,6 +157,24 @@ func main() { if flagSyncLDAP { auth.SyncWithLDAP(db) } + + if flagGenJWT != "" { + user, err := auth.FetchUserFromDB(db, flagGenJWT) + if err != nil { + log.Fatal(err) + } + + if !user.IsAPIUser { + log.Println("warning: that user does not have the API role") + } + + jwt, err := auth.ProvideJWT(user) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("JWT for '%s': %s\n", user.Username, jwt) + } } else if flagNewUser != "" || flagDelUser != "" { log.Fatalln("arguments --add-user and --del-user can only be used if authentication is enabled") } @@ -182,6 +201,18 @@ func main() { resolver := &graph.Resolver{DB: db} graphQLEndpoint := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: resolver})) + + // graphQLEndpoint.SetRecoverFunc(func(ctx context.Context, err interface{}) error { + // switch e := err.(type) { + // case string: + // return fmt.Errorf("panic: %s", e) + // case error: + // return fmt.Errorf("panic caused by: %w", e) + // } + + // return errors.New("internal server error (panic)") + // }) + graphQLPlayground := playground.Handler("GraphQL playground", "/query") api := &api.RestApi{ DB: db, @@ -191,7 +222,7 @@ func main() { } handleGetLogin := func(rw http.ResponseWriter, r *http.Request) { - templates.Render(rw, r, "login", &templates.Page{ + templates.Render(rw, r, "login.html", &templates.Page{ Title: "Login", Login: &templates.LoginPage{}, }) @@ -199,7 +230,7 @@ func main() { r := mux.NewRouter() r.NotFoundHandler = http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - templates.Render(rw, r, "404", &templates.Page{ + templates.Render(rw, r, "404.html", &templates.Page{ Title: "Not found", }) }) @@ -215,8 +246,6 @@ func main() { } secured.Handle("/query", graphQLEndpoint) - secured.HandleFunc("/config.json", config.ServeConfig).Methods(http.MethodGet) - secured.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { conf, err := config.GetUIConfig(r) if err != nil { @@ -235,7 +264,7 @@ func main() { infos["admin"] = user.IsAdmin } - templates.Render(rw, r, "home", &templates.Page{ + templates.Render(rw, r, "home.html", &templates.Page{ Title: "ClusterCockpit", Config: conf, Infos: infos, @@ -297,7 +326,7 @@ func monitoringRoutes(router *mux.Router, resolver *graph.Resolver) { return } - templates.Render(rw, r, "monitoring/jobs/", &templates.Page{ + templates.Render(rw, r, "monitoring/jobs.html", &templates.Page{ Title: "Jobs - ClusterCockpit", Config: conf, FilterPresets: buildFilterPresets(r.URL.Query()), @@ -318,7 +347,7 @@ func monitoringRoutes(router *mux.Router, resolver *graph.Resolver) { return } - templates.Render(rw, r, "monitoring/job/", &templates.Page{ + templates.Render(rw, r, "monitoring/job.html", &templates.Page{ Title: fmt.Sprintf("Job %d - ClusterCockpit", job.JobID), Config: conf, Infos: map[string]interface{}{ @@ -336,7 +365,7 @@ func monitoringRoutes(router *mux.Router, resolver *graph.Resolver) { return } - templates.Render(rw, r, "monitoring/users/", &templates.Page{ + templates.Render(rw, r, "monitoring/users.html", &templates.Page{ Title: "Users - ClusterCockpit", Config: conf, }) @@ -353,7 +382,7 @@ func monitoringRoutes(router *mux.Router, resolver *graph.Resolver) { // TODO: One could check if the user exists, but that would be unhelpfull if authentication // is disabled or the user does not exist but has started jobs. - templates.Render(rw, r, "monitoring/user/", &templates.Page{ + templates.Render(rw, r, "monitoring/user.html", &templates.Page{ Title: fmt.Sprintf("User %s - ClusterCockpit", id), Config: conf, Infos: map[string]interface{}{"username": id}, @@ -374,7 +403,7 @@ func monitoringRoutes(router *mux.Router, resolver *graph.Resolver) { filterPresets["clusterId"] = query.Get("cluster") } - templates.Render(rw, r, "monitoring/analysis/", &templates.Page{ + templates.Render(rw, r, "monitoring/analysis.html", &templates.Page{ Title: "Analysis View - ClusterCockpit", Config: conf, FilterPresets: filterPresets, @@ -394,7 +423,7 @@ func monitoringRoutes(router *mux.Router, resolver *graph.Resolver) { filterPresets["clusterId"] = query.Get("cluster") } - templates.Render(rw, r, "monitoring/systems/", &templates.Page{ + templates.Render(rw, r, "monitoring/systems.html", &templates.Page{ Title: "System View - ClusterCockpit", Config: conf, FilterPresets: filterPresets, @@ -409,7 +438,7 @@ func monitoringRoutes(router *mux.Router, resolver *graph.Resolver) { } vars := mux.Vars(r) - templates.Render(rw, r, "monitoring/node/", &templates.Page{ + templates.Render(rw, r, "monitoring/node.html", &templates.Page{ Title: fmt.Sprintf("Node %s - ClusterCockpit", vars["nodeId"]), Config: conf, Infos: map[string]interface{}{ diff --git a/templates/monitoring/jobs.html b/templates/monitoring/jobs.html index 1d70968..9733678 100644 --- a/templates/monitoring/jobs.html +++ b/templates/monitoring/jobs.html @@ -8,13 +8,7 @@ {{define "javascript"}} {{end}} diff --git a/templates/monitoring/user.html b/templates/monitoring/user.html index ee16cdc..693ae61 100644 --- a/templates/monitoring/user.html +++ b/templates/monitoring/user.html @@ -7,16 +7,9 @@ {{end}} {{define "javascript"}} {{end}} diff --git a/templates/templates.go b/templates/templates.go index 327ef19..1ab66d7 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -6,7 +6,9 @@ import ( "net/http" ) -var templates map[string]*template.Template +var templatesDir string +var debugMode bool = true +var templates map[string]*template.Template = map[string]*template.Template{} type Page struct { Title string @@ -22,27 +24,32 @@ type LoginPage struct { } func init() { - base := template.Must(template.ParseFiles("./templates/base.html")) - templates = map[string]*template.Template{ - "home": template.Must(template.Must(base.Clone()).ParseFiles("./templates/home.html")), - "404": template.Must(template.Must(base.Clone()).ParseFiles("./templates/404.html")), - "login": template.Must(template.Must(base.Clone()).ParseFiles("./templates/login.html")), - "monitoring/jobs/": template.Must(template.Must(base.Clone()).ParseFiles("./templates/monitoring/jobs.html")), - "monitoring/job/": template.Must(template.Must(base.Clone()).ParseFiles("./templates/monitoring/job.html")), - "monitoring/users/": template.Must(template.Must(base.Clone()).ParseFiles("./templates/monitoring/users.html")), - "monitoring/user/": template.Must(template.Must(base.Clone()).ParseFiles("./templates/monitoring/user.html")), - "monitoring/analysis/": template.Must(template.Must(base.Clone()).ParseFiles("./templates/monitoring/analysis.html")), - "monitoring/systems/": template.Must(template.Must(base.Clone()).ParseFiles("./templates/monitoring/systems.html")), - "monitoring/node/": template.Must(template.Must(base.Clone()).ParseFiles("./templates/monitoring/node.html")), + templatesDir = "./templates/" + base := template.Must(template.ParseFiles(templatesDir + "base.html")) + files := []string{ + "home.html", "404.html", "login.html", + "monitoring/jobs.html", "monitoring/job.html", + "monitoring/users.html", "monitoring/user.html", + "monitoring/analysis.html", + "monitoring/systems.html", + "monitoring/node.html", + } + + for _, file := range files { + templates[file] = template.Must(template.Must(base.Clone()).ParseFiles(templatesDir + file)) } } -func Render(rw http.ResponseWriter, r *http.Request, name string, page *Page) { - t, ok := templates[name] +func Render(rw http.ResponseWriter, r *http.Request, file string, page *Page) { + t, ok := templates[file] if !ok { panic("templates must be predefinied!") } + if debugMode { + t = template.Must(template.ParseFiles(templatesDir+"base.html", templatesDir+file)) + } + if err := t.Execute(rw, page); err != nil { log.Printf("template error: %s\n", err.Error()) }