diff --git a/cmd/boardvoting/config.go b/cmd/boardvoting/config.go new file mode 100644 index 0000000..de60fee --- /dev/null +++ b/cmd/boardvoting/config.go @@ -0,0 +1,125 @@ +/* +Copyright 2017-2022 CAcert Inc. +SPDX-License-Identifier: Apache-2.0 + +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. +*/ + +package main + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io/ioutil" + + "github.com/gorilla/sessions" + "gopkg.in/yaml.v2" +) + +const ( + cookieSecretMinLen = 32 + csrfKeyLength = 32 + httpIdleTimeout = 5 + httpReadHeaderTimeout = 10 + httpReadTimeout = 10 + httpWriteTimeout = 60 + sessionCookieName = "votesession" +) + +type Config struct { + NoticeMailAddress string `yaml:"notice_mail_address"` + VoteNoticeMailAddress string `yaml:"vote_notice_mail_address"` + NotificationSenderAddress string `yaml:"notification_sender_address"` + DatabaseFile string `yaml:"database_file"` + ClientCACertificates string `yaml:"client_ca_certificates"` + ServerCert string `yaml:"server_certificate"` + ServerKey string `yaml:"server_key"` + CookieSecret string `yaml:"cookie_secret"` + CsrfKey string `yaml:"csrf_key"` + BaseURL string `yaml:"base_url"` + HTTPAddress string `yaml:"http_address,omitempty"` + HTTPSAddress string `yaml:"https_address,omitempty"` + MailServer struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + } `yaml:"mail_server"` +} + +type configKey int + +const ( + ctxConfig configKey = iota + ctxCookieStore +) + +func parseConfig(ctx context.Context, configFile string) (context.Context, error) { + source, err := ioutil.ReadFile(configFile) + if err != nil { + return nil, fmt.Errorf("could not read configuration file %s: %w", configFile, err) + } + + config := &Config{ + HTTPAddress: "127.0.0.1:8000", + HTTPSAddress: "127.0.0.1:8433", + } + + if err := yaml.Unmarshal(source, config); err != nil { + return nil, fmt.Errorf("could not parse configuration: %w", err) + } + + cookieSecret, err := base64.StdEncoding.DecodeString(config.CookieSecret) + if err != nil { + return nil, fmt.Errorf("could not decode cookie secret: %w", err) + } + + if len(cookieSecret) < cookieSecretMinLen { + return nil, fmt.Errorf("cookie secret is less than the minimum require %d bytes long", cookieSecretMinLen) + } + + csrfKey, err := base64.StdEncoding.DecodeString(config.CsrfKey) + if err != nil { + return nil, fmt.Errorf("could not decode CSRF key: %w", err) + } + + if len(csrfKey) != csrfKeyLength { + return nil, fmt.Errorf("CSRF key must be exactly %d bytes long but is %d bytes long", csrfKeyLength, len(csrfKey)) + } + + cookieStore := sessions.NewCookieStore(cookieSecret) + cookieStore.Options.Secure = true + + ctx = context.WithValue(ctx, ctxConfig, config) + ctx = context.WithValue(ctx, ctxCookieStore, cookieStore) + + return ctx, nil +} + +func GetConfig(ctx context.Context) (*Config, error) { + config, ok := ctx.Value(ctxConfig).(*Config) + if !ok { + return nil, errors.New("invalid value type for config in context") + } + + return config, nil +} + +func GetCookieStore(ctx context.Context) (*sessions.CookieStore, error) { + cookieStore, ok := ctx.Value(ctxConfig).(*sessions.CookieStore) + if !ok { + return nil, errors.New("invalid value type for cookie store in context") + } + + return cookieStore, nil +} diff --git a/cmd/boardvoting/handlers.go b/cmd/boardvoting/handlers.go new file mode 100644 index 0000000..73ba0a5 --- /dev/null +++ b/cmd/boardvoting/handlers.go @@ -0,0 +1,68 @@ +/* +Copyright 2017-2022 CAcert Inc. +SPDX-License-Identifier: Apache-2.0 + +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. +*/ + +package main + +import ( + "html/template" + "net/http" +) + +func (app *application) motions(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/motions/" { + http.NotFound(w, r) + + return + } + + files := []string{ + "./ui/html/base.html", + "./ui/html/partials/nav.html", + "./ui/html/pages/motions.html", + } + + ts, err := template.ParseFiles(files...) + if err != nil { + app.errorLog.Print(err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + return + } + + err = ts.ExecuteTemplate(w, "base", nil) + if err != nil { + app.errorLog.Print(err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } +} + +func (app *application) home(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + + return + } + + if r.Method != "GET" && r.Method != "HEAD" { + w.Header().Set("Allow", "GET,HEAD") + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + + return + } + + http.Redirect(w, r, "/motions/", http.StatusMovedPermanently) +} diff --git a/cmd/boardvoting/main.go b/cmd/boardvoting/main.go index 3c72ec5..a93504c 100644 --- a/cmd/boardvoting/main.go +++ b/cmd/boardvoting/main.go @@ -18,7 +18,19 @@ limitations under the License. // The CAcert board voting software. package main -import log "github.com/sirupsen/logrus" +import ( + "context" + "flag" + "io/fs" + "log" + "net/http" + "os" + + "github.com/vearutop/statigz" + "github.com/vearutop/statigz/brotli" + + "git.cacert.org/cacert-boardvoting/ui" +) var ( version = "undefined" @@ -26,7 +38,58 @@ var ( date = "undefined" ) +type application struct { + errorLog, infoLog *log.Logger +} + func main() { - log.SetFormatter(&log.TextFormatter{FullTimestamp: true}) - log.Infof("CAcert Board Voting version %s, commit %s built at %s", version, commit, date) + configFile := flag.String("config", "config.yaml", "Configuration file name") + flag.Parse() + + infoLog := log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime) + errorLog := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime) + + infoLog.Printf("CAcert Board Voting version %s, commit %s built at %s", version, commit, date) + + ctx, err := parseConfig(context.Background(), *configFile) + if err != nil { + errorLog.Fatal(err) + } + + mux := http.NewServeMux() + + staticDir, _ := fs.Sub(ui.Files, "static") + + staticData, ok := staticDir.(fs.ReadDirFS) + if !ok { + errorLog.Fatal("could not use uiStaticDir as fs.ReadDirFS") + } + + fileServer := statigz.FileServer(staticData, brotli.AddEncoding, statigz.EncodeOnInit) + + mux.Handle("/static/", http.StripPrefix("/static", fileServer)) + + app := &application{ + errorLog: errorLog, + infoLog: infoLog, + } + + mux.HandleFunc("/", app.home) + mux.HandleFunc("/motions/", app.motions) + + config, err := GetConfig(ctx) + if err != nil { + errorLog.Fatal(err) + } + + srv := &http.Server{ + Addr: config.HTTPAddress, + ErrorLog: errorLog, + Handler: mux, + } + + infoLog.Printf("Starting server on %s", config.HTTPAddress) + + err = srv.ListenAndServe() + errorLog.Fatal(err) } diff --git a/models.go b/models.go index 6a614f2..368785a 100644 --- a/models.go +++ b/models.go @@ -330,7 +330,6 @@ func NewDB(database *sql.DB) *DbHandler { failedStatements = append(failedStatements, sqlStatement) } - // nolint:sqlclosecheck _ = stmt.Close() } diff --git a/ui/efs.go b/ui/efs.go new file mode 100644 index 0000000..0cd8254 --- /dev/null +++ b/ui/efs.go @@ -0,0 +1,24 @@ +/* +Copyright 2022 CAcert Inc. +SPDX-License-Identifier: Apache-2.0 + +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. +*/ + +// Package ui provides files for the CAcert board voting system user interface. +package ui + +import "embed" + +//go:embed "html" "static" +var Files embed.FS diff --git a/ui/html/base.html b/ui/html/base.html new file mode 100644 index 0000000..5fe5a94 --- /dev/null +++ b/ui/html/base.html @@ -0,0 +1,55 @@ +{{ define "base" }} + + + + + {{ template "title" . }} - CAcert Board Voting System + + + + +
+
+
+ CAcert +
+
+

+ {{ template "title" . }} + {{ if .Voter }} + Authenticated as {{ .Voter.Name }} <{{ .Voter.Reminder }}> + + {{ end }} +

+
+
+
+{{ template "nav" . }} +
+ {{ with .Flashes }} +
+
+ +
+ {{ range . }} +
{{ . }}
+ {{ end }} +
+
+
+ {{ end }} + {{ template "main" . }} +
+ + + + + + +{{ end }} \ No newline at end of file diff --git a/ui/html/pages/motions.html b/ui/html/pages/motions.html new file mode 100644 index 0000000..5759ce8 --- /dev/null +++ b/ui/html/pages/motions.html @@ -0,0 +1,6 @@ +{{ define "title" }}Motions{{ end }} + +{{ define "main" }} +

All the motions

+

Nothing to see yet.

+{{ end }} diff --git a/ui/html/partials/nav.html b/ui/html/partials/nav.html new file mode 100644 index 0000000..6e7dabe --- /dev/null +++ b/ui/html/partials/nav.html @@ -0,0 +1,5 @@ +{{ define "nav" }} + +{{ end }} \ No newline at end of file