diff --git a/Makefile b/Makefile index 8387900..09a216f 100644 --- a/Makefile +++ b/Makefile @@ -1,34 +1,34 @@ GOCMD=go GOINSTALL=$(GOCMD) install GOBUILD=$(GOCMD) build GOTEST=$(GOCMD) test GOGET=$(GOCMD) get BINARY_NAME=writefreely all : build build: deps cd cmd/writefreely; $(GOBUILD) -v test: $(GOTEST) -v ./... run: $(GOINSTALL) ./... $(BINARY_NAME) --debug deps : $(GOGET) -v ./... -install : - ./keys.sh +install : build + cmd/writefreely/$(BINARY_NAME) --gen-keys cd less/; $(MAKE) install $(MFLAGS) ui : force_look cd less/; $(MAKE) $(MFLAGS) clean : cd less/; $(MAKE) clean $(MFLAGS) force_look : true diff --git a/README.md b/README.md index 0393695..570b3ea 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,92 @@  

Write Freely


Latest release Go Report Card Build status

  WriteFreely is a beautifully pared-down blogging platform that's simple on the surface, yet powerful underneath. It's designed to be flexible and share your writing widely, so it's built around plain text and can publish to the _fediverse_ via ActivityPub. It's easy to install and lightweight. **[Start a blog on our instance](https://write.as/new/blog/federated)** [Try the editor](https://write.as/new) [Find another instance](https://writefreely.org/instances) ## Features * Start a blog for yourself, or host a community of writers * Form larger federated networks, and interact over modern protocols like ActivityPub * Write on a dead-simple, distraction-free and super fast editor * Publish drafts and let others proofread them by sharing a private link * Build more advanced apps and extensions with the [well-documented API](https://developers.write.as/docs/api/) ## Quick start > **Note** this is currently alpha software. We're quickly moving out of this v0.x stage, but while we're in it, there are no guarantees that this is ready for production use. First, download the [latest release](https://github.com/writeas/writefreely/releases/latest) for your OS. It includes everything you need to start your blog. Now extract the files from the archive, change into the directory, and do the following steps: ```bash # 1) Log into MySQL and run: # CREATE DATABASE writefreely; # # 2) Import the schema with: mysql -u YOURUSERNAME -p writefreely < schema.sql # 3) Configure your blog ./writefreely --config -# 4) Generate data encryption keys (especially for production) -./keys.sh +# 4) Generate data encryption keys +./writefreely --gen-keys # 5) Run ./writefreely # 6) Check out your site at the URL you specified in the setup process # 7) There is no Step 7, you're done! ``` For running in production, [see our guide](https://writefreely.org/start#production). ## Development Ready to hack on your site? Here's a quick overview. ### Prerequisites * [Go 1.10+](https://golang.org/dl/) * [Node.js](https://nodejs.org/en/download/) ### Setting up ```bash go get github.com/writeas/writefreely/cmd/writefreely ``` -Create your database, import the schema, and configure your site [as shown above](#quick-start). - -Now generate the CSS: +Create your database, import the schema, and configure your site [as shown above](#quick-start). Then generate the remaining files you'll need: ```bash make install # Generates encryption keys; installs LESS compiler make ui # Generates CSS (run this whenever you update your styles) make run # Runs the application ``` ## License Licensed under the AGPL. diff --git a/app.go b/app.go index c50d0db..635c2f6 100644 --- a/app.go +++ b/app.go @@ -1,273 +1,291 @@ package writefreely import ( "database/sql" "flag" "fmt" _ "github.com/go-sql-driver/mysql" "html/template" "net/http" "os" "os/signal" "regexp" "syscall" "time" "github.com/gorilla/mux" "github.com/gorilla/schema" "github.com/gorilla/sessions" "github.com/writeas/web-core/converter" "github.com/writeas/web-core/log" "github.com/writeas/writefreely/config" "github.com/writeas/writefreely/page" ) const ( staticDir = "static/" assumedTitleLen = 80 postsPerPage = 10 serverSoftware = "WriteFreely" softwareURL = "https://writefreely.org" softwareVer = "0.1" ) var ( debugging bool // DEPRECATED VARS // TODO: pass app.cfg into GetCollection* calls so we can get these values // from Collection methods and we no longer need these. hostName string isSingleUser bool ) type app struct { router *mux.Router db *datastore cfg *config.Config keys *keychain sessionStore *sessions.CookieStore formDecoder *schema.Decoder } // handleViewHome shows page at root path. Will be the Pad if logged in and the // catch-all landing page otherwise. func handleViewHome(app *app, w http.ResponseWriter, r *http.Request) error { if app.cfg.App.SingleUser { // Render blog index return handleViewCollection(app, w, r) } // Multi-user instance u := getUserSession(app, r) if u != nil { // User is logged in, so show the Pad return handleViewPad(app, w, r) } p := struct { page.StaticPage Flashes []template.HTML }{ StaticPage: pageForReq(app, r), } // Get error messages session, err := app.sessionStore.Get(r, cookieName) if err != nil { // Ignore this log.Error("Unable to get session in handleViewHome; ignoring: %v", err) } flashes, _ := getSessionFlashes(app, w, r, session) for _, flash := range flashes { p.Flashes = append(p.Flashes, template.HTML(flash)) } // Show landing page return renderPage(w, "landing.tmpl", p) } func pageForReq(app *app, r *http.Request) page.StaticPage { p := page.StaticPage{ AppCfg: app.cfg.App, Path: r.URL.Path, Version: "v" + softwareVer, } // Add user information, if given var u *User accessToken := r.FormValue("t") if accessToken != "" { userID := app.db.GetUserID(accessToken) if userID != -1 { var err error u, err = app.db.GetUserByID(userID) if err == nil { p.Username = u.Username } } } else { u = getUserSession(app, r) if u != nil { p.Username = u.Username } } return p } var shttp = http.NewServeMux() var fileRegex = regexp.MustCompile("/([^/]*\\.[^/]*)$") func Serve() { debugPtr := flag.Bool("debug", false, "Enables debug logging.") createConfig := flag.Bool("create-config", false, "Creates a basic configuration and exits") doConfig := flag.Bool("config", false, "Run the configuration process") + genKeys := flag.Bool("gen-keys", false, "Generate encryption and authentication keys") flag.Parse() debugging = *debugPtr app := &app{} if *createConfig { log.Info("Creating configuration...") c := config.New() log.Info("Saving configuration...") err := config.Save(c) if err != nil { log.Error("Unable to save configuration: %v", err) os.Exit(1) } os.Exit(0) } else if *doConfig { d, err := config.Configure() if err != nil { log.Error("Unable to configure: %v", err) os.Exit(1) } if d.User != nil { app.cfg = d.Config connectToDatabase(app) defer shutdown(app) u := &User{ Username: d.User.Username, HashedPass: d.User.HashedPass, Created: time.Now().Truncate(time.Second).UTC(), } // Create blog log.Info("Creating user %s...\n", u.Username) err = app.db.CreateUser(u, app.cfg.App.SiteName) if err != nil { log.Error("Unable to create user: %s", err) os.Exit(1) } log.Info("Done!") } os.Exit(0) + } else if *genKeys { + errStatus := 0 + + err := generateKey(emailKeyPath) + if err != nil { + errStatus = 1 + } + err = generateKey(cookieAuthKeyPath) + if err != nil { + errStatus = 1 + } + err = generateKey(cookieKeyPath) + if err != nil { + errStatus = 1 + } + + os.Exit(errStatus) } log.Info("Initializing...") log.Info("Loading configuration...") cfg, err := config.Load() if err != nil { log.Error("Unable to load configuration: %v", err) os.Exit(1) } app.cfg = cfg hostName = cfg.App.Host isSingleUser = cfg.App.SingleUser app.cfg.Server.Dev = *debugPtr initTemplates() // Load keys log.Info("Loading encryption keys...") err = initKeys(app) if err != nil { log.Error("\n%s\n", err) } // Initialize modules app.sessionStore = initSession(app) app.formDecoder = schema.NewDecoder() app.formDecoder.RegisterConverter(converter.NullJSONString{}, converter.ConvertJSONNullString) app.formDecoder.RegisterConverter(converter.NullJSONBool{}, converter.ConvertJSONNullBool) app.formDecoder.RegisterConverter(sql.NullString{}, converter.ConvertSQLNullString) app.formDecoder.RegisterConverter(sql.NullBool{}, converter.ConvertSQLNullBool) app.formDecoder.RegisterConverter(sql.NullInt64{}, converter.ConvertSQLNullInt64) app.formDecoder.RegisterConverter(sql.NullFloat64{}, converter.ConvertSQLNullFloat64) // Check database configuration if app.cfg.Database.User == "" || app.cfg.Database.Password == "" { log.Error("Database user or password not set.") os.Exit(1) } if app.cfg.Database.Host == "" { app.cfg.Database.Host = "localhost" } if app.cfg.Database.Database == "" { app.cfg.Database.Database = "writeas" } connectToDatabase(app) defer shutdown(app) r := mux.NewRouter() handler := NewHandler(app) handler.SetErrorPages(&ErrorPages{ NotFound: pages["404-general.tmpl"], Gone: pages["410.tmpl"], InternalServerError: pages["500.tmpl"], Blank: pages["blank.tmpl"], }) // Handle app routes initRoutes(handler, r, app.cfg, app.db) // Handle static files fs := http.FileServer(http.Dir(staticDir)) shttp.Handle("/", fs) r.PathPrefix("/").Handler(fs) // Handle shutdown c := make(chan os.Signal, 2) signal.Notify(c, os.Interrupt, syscall.SIGTERM) go func() { <-c log.Info("Shutting down...") shutdown(app) log.Info("Done.") os.Exit(0) }() // Start web application server http.Handle("/", r) log.Info("Serving on http://localhost:%d\n", app.cfg.Server.Port) log.Info("---") err = http.ListenAndServe(fmt.Sprintf(":%d", app.cfg.Server.Port), nil) if err != nil { log.Error("Unable to start: %v", err) os.Exit(1) } } func connectToDatabase(app *app) { log.Info("Connecting to database...") db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database)) if err != nil { log.Error("%s", err) os.Exit(1) } app.db = &datastore{db} app.db.SetMaxOpenConns(50) } func shutdown(app *app) { log.Info("Closing database connection...") app.db.Close() } diff --git a/keys.go b/keys.go index 7058111..b5d8ede 100644 --- a/keys.go +++ b/keys.go @@ -1,42 +1,83 @@ package writefreely import ( + "crypto/rand" + "github.com/writeas/web-core/log" "io/ioutil" + "os" "path/filepath" ) const ( keysDir = "keys" + + encKeysBytes = 32 ) var ( emailKeyPath = filepath.Join(keysDir, "email.aes256") cookieAuthKeyPath = filepath.Join(keysDir, "cookies_auth.aes256") cookieKeyPath = filepath.Join(keysDir, "cookies_enc.aes256") ) type keychain struct { emailKey, cookieAuthKey, cookieKey []byte } func initKeys(app *app) error { var err error app.keys = &keychain{} app.keys.emailKey, err = ioutil.ReadFile(emailKeyPath) if err != nil { return err } app.keys.cookieAuthKey, err = ioutil.ReadFile(cookieAuthKeyPath) if err != nil { return err } app.keys.cookieKey, err = ioutil.ReadFile(cookieKeyPath) if err != nil { return err } return nil } + +// generateKey generates a key at the given path used for the encryption of +// certain user data. Because user data becomes unrecoverable without these +// keys, this won't overwrite any existing key, and instead outputs a message. +func generateKey(path string) error { + // Check if key file exists + if _, err := os.Stat(path); !os.IsNotExist(err) { + log.Info("%s already exists. rm the file if you understand the consquences.", path) + return nil + } + + log.Info("Generating %s.", path) + b, err := generateBytes(encKeysBytes) + if err != nil { + log.Error("FAILED. %s. Run writefreely --gen-keys again.", err) + return err + } + err = ioutil.WriteFile(path, b, 0600) + if err != nil { + log.Error("FAILED writing file: %s", err) + return err + } + log.Info("Success.") + return nil +} + +// generateBytes returns securely generated random bytes. +func generateBytes(n int) ([]byte, error) { + b := make([]byte, n) + _, err := rand.Read(b) + if err != nil { + return nil, err + } + + return b, nil +} diff --git a/keys.sh b/keys.sh deleted file mode 100755 index d4c0c22..0000000 --- a/keys.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash -# -# keys.sh generates keys used for the encryption of certain user data. Because -# user data becomes unrecoverable without these keys, the script and won't -# overwrite any existing keys unless you explicitly delete them. -# - -# Generate cookie encryption and authentication keys -if [[ ! -e "$(pwd)/keys/cookies_enc.aes256" ]]; then - dd of=$(pwd)/keys/cookies_enc.aes256 if=/dev/urandom bs=32 count=1 -else - echo "cookies key already exists! rm keys/cookies_enc.aes256 if you understand the consquences." -fi -if [[ ! -e "$(pwd)/keys/cookies_auth.aes256" ]]; then - dd of=$(pwd)/keys/cookies_auth.aes256 if=/dev/urandom bs=32 count=1 -else - echo "cookies authentication key already exists! rm keys/cookies_auth.aes256 if you understand the consquences." -fi - -# Generate email encryption key -if [[ ! -e "$(pwd)/keys/email.aes256" ]]; then - dd of=$(pwd)/keys/email.aes256 if=/dev/urandom bs=32 count=1 -else - echo "email key already exists! rm keys/email.aes256 if you understand the consquences." -fi diff --git a/keys/README.md b/keys/README.md index 966b9a9..49c7417 100644 --- a/keys/README.md +++ b/keys/README.md @@ -1,4 +1,4 @@ Keys ==== -Contains keys for encrypting database and session data. Generate necessary keys by running (from the root of the project) `./keys.sh`. +Contains keys for encrypting database and session data. Generate necessary keys by running (from the root of the project) `writefreely --gen-keys`.