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 @@
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`.