diff --git a/README.md b/README.md index bde2d7d..ec8ec17 100644 --- a/README.md +++ b/README.md @@ -1,157 +1,159 @@  

Write Freely


Latest release Go Report Card Build status #writefreely on freenode

  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 light enough to run on a Raspberry Pi. [Try the editor](https://write.as/new) [Find an 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 fast, dead-simple, and distraction-free editor -* Format text with Markdown, and organize posts with hashtags +* [Format text](https://howto.write.as/getting-started) with Markdown +* [Organize posts](https://howto.write.as/organization) with hashtags +* Create [static pages](https://howto.write.as/creating-a-static-page) * Publish drafts and let others proofread them by sharing a private link * Create multiple lightweight blogs under a single account * Export all data in plain text files * Read a stream of other posts in your writing community * Build more advanced apps and extensions with the [well-documented API](https://developers.write.as/docs/api/) * Designed around user privacy and consent ## Hosting We offer two kinds of hosting services that make WriteFreely deployment painless: [Write.as](https://write.as) for individuals, and [WriteFreely.host](https://writefreely.host) for communities. Besides saving you time, as a customer you directly help fund WriteFreely development. ### [![Write.as](https://write.as/img/writeas-wf-readme.png)](https://write.as/) Start a personal blog on [Write.as](https://write.as), our flagship instance. Built to eliminate setup friction and preserve your privacy, Write.as helps you start a blog in seconds. It supports custom domains (with SSL) and multiple blogs / pen names per account. [Read more here](https://write.as/pricing). ### [![WriteFreely.host](https://writefreely.host/img/wfhost-wf-readme.png)](https://writefreely.host) [WriteFreely.host](https://writefreely.host) makes it easy to start a close-knit community — to share knowledge, complement your Mastodon instance, or publish updates in your organization. We take care of the hosting, upgrades, backups, and maintenance so you can focus on writing. ## Quick start WriteFreely has minimal requirements to get up and running — you only need to be able to run an executable. > **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) Configure your blog ./writefreely --config # 2) (if you chose MySQL in the previous step) Log into MySQL and run: # CREATE DATABASE writefreely; # 3) (if you chose Multi-user setup) Import the schema with: ./writefreely --init-db # 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). ## Packages WriteFreely is available in these package repositories: * [Arch User Repository](https://aur.archlinux.org/packages/writefreely/) ## 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 +go get -d github.com/writeas/writefreely/cmd/writefreely ``` Configure your site, create your database, and import the schema [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 ``` ## Docker ### Using Docker for Development If you'd like to use Docker as a base for working on a site's styles and such, you can run the following from a Bash shell. *Note: This process is intended only for working on site styling. If you'd like to run Write Freely in production as a Docker service, it'll require a little more work.* The `docker-setup.sh` script will present you with a few questions to set up your dev instance. You can hit enter for most of them, except for "Admin username" and "Admin password." You'll probably have to wait a few seconds after running `docker-compose up -d` for the Docker services to come up before running the bash script. ``` docker-compose up -d ./docker-setup.sh ``` Now you should be able to navigate to http://localhost:8080 and start working! When you're completely done working, you can run `docker-compose down` to destroy your virtual environment, including your database data. Otherwise, `docker-compose stop` will shut down your environment without destroying your data. ### Using Docker for Production Write Freely doesn't yet provide an official Docker pathway to production. We're working on it, though! ## Contributing -We gladly welcome contributions to WriteFreely, whether in the form of [code](https://github.com/writeas/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely), [bug reports](https://github.com/writeas/writefreely/issues/new?template=bug_report.md), [feature requests](https://discuss.write.as/c/feedback/feature-requests), [translations](https://poeditor.com/join/project/TIZ6HFRFdE), or documentation improvements. +We gladly welcome contributions to WriteFreely, whether in the form of [code](https://github.com/writeas/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely), [bug reports](https://github.com/writeas/writefreely/issues/new?template=bug_report.md), [feature requests](https://discuss.write.as/c/feedback/feature-requests), [translations](https://poeditor.com/join/project/TIZ6HFRFdE), or [documentation](https://github.com/writefreely/documentation) improvements. Before contributing anything, please read our [Contributing Guide](https://github.com/writeas/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely). It describes the correct channels for submitting contributions and any potential requirements. ## License Licensed under the AGPL. diff --git a/app.go b/app.go index 202b3e8..6b5fbb4 100644 --- a/app.go +++ b/app.go @@ -1,632 +1,647 @@ /* * Copyright © 2018 A Bunch Tell LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "database/sql" "flag" "fmt" "html/template" "net/http" "net/url" "os" "os/signal" "path/filepath" "regexp" "strings" "syscall" "time" _ "github.com/go-sql-driver/mysql" "github.com/gorilla/mux" "github.com/gorilla/schema" "github.com/gorilla/sessions" "github.com/manifoldco/promptui" "github.com/writeas/go-strip-markdown" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/converter" "github.com/writeas/web-core/log" "github.com/writeas/writefreely/author" "github.com/writeas/writefreely/config" "github.com/writeas/writefreely/migrations" "github.com/writeas/writefreely/page" ) const ( staticDir = "static" assumedTitleLen = 80 postsPerPage = 10 serverSoftware = "WriteFreely" softwareURL = "https://writefreely.org" ) var ( debugging bool // Software version can be set from git env using -ldflags softwareVer = "0.8.0" // 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 cfgFile string keys *keychain sessionStore *sessions.CookieStore formDecoder *schema.Decoder timeline *localTimeline } // 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 handleTemplatedPage(app *app, w http.ResponseWriter, r *http.Request, t *template.Template) error { p := struct { page.StaticPage Content template.HTML PlainContent string Updated string AboutStats *InstanceStats }{ StaticPage: pageForReq(app, r), } if r.URL.Path == "/about" || r.URL.Path == "/privacy" { var c string var updated *time.Time var err error if r.URL.Path == "/about" { c, err = getAboutPage(app) // Fetch stats p.AboutStats = &InstanceStats{} p.AboutStats.NumPosts, _ = app.db.GetTotalPosts() p.AboutStats.NumBlogs, _ = app.db.GetTotalCollections() } else { c, updated, err = getPrivacyPage(app) } if err != nil { return err } p.Content = template.HTML(applyMarkdown([]byte(c))) p.PlainContent = shortPostDescription(stripmd.Strip(c)) if updated != nil { p.Updated = updated.Format("January 2, 2006") } } // Serve templated page err := t.ExecuteTemplate(w, "base", p) if err != nil { log.Error("Unable to render page: %v", err) } return nil } 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() { // General options usable with other commands debugPtr := flag.Bool("debug", false, "Enables debug logging.") configFile := flag.String("c", "config.ini", "The configuration file to use") // Setup actions 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") createSchema := flag.Bool("init-db", false, "Initialize app database") migrate := flag.Bool("migrate", false, "Migrate the database") // Admin actions createAdmin := flag.String("create-admin", "", "Create an admin with the given username:password") createUser := flag.String("create-user", "", "Create a regular user with the given username:password") resetPassUser := flag.String("reset-pass", "", "Reset the given user's password") outputVersion := flag.Bool("v", false, "Output the current version") flag.Parse() debugging = *debugPtr app := &app{ cfgFile: *configFile, } if *outputVersion { fmt.Println(serverSoftware + " " + softwareVer) os.Exit(0) } else if *createConfig { log.Info("Creating configuration...") c := config.New() log.Info("Saving configuration %s...", app.cfgFile) err := config.Save(c, app.cfgFile) if err != nil { log.Error("Unable to save configuration: %v", err) os.Exit(1) } os.Exit(0) } else if *doConfig { d, err := config.Configure(app.cfgFile) 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) if !app.db.DatabaseInitialized() { - adminInitDatabase(app) + err = adminInitDatabase(app) + if err != nil { + log.Error(err.Error()) + os.Exit(1) + } } 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 // Read keys path from config loadConfig(app) // Create keys dir if it doesn't exist yet fullKeysDir := filepath.Join(app.cfg.Server.KeysParentDir, keysDir) if _, err := os.Stat(fullKeysDir); os.IsNotExist(err) { err = os.Mkdir(fullKeysDir, 0700) if err != nil { log.Error("%s", err) os.Exit(1) } } // Generate keys initKeyPaths(app) 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) } else if *createSchema { loadConfig(app) connectToDatabase(app) defer shutdown(app) - adminInitDatabase(app) + err := adminInitDatabase(app) + if err != nil { + log.Error(err.Error()) + os.Exit(1) + } + os.Exit(0) } else if *createAdmin != "" { - adminCreateUser(app, *createAdmin, true) + err := adminCreateUser(app, *createAdmin, true) + if err != nil { + log.Error(err.Error()) + os.Exit(1) + } + os.Exit(0) } else if *createUser != "" { - adminCreateUser(app, *createUser, false) + err := adminCreateUser(app, *createUser, false) + if err != nil { + log.Error(err.Error()) + os.Exit(1) + } + os.Exit(0) } else if *resetPassUser != "" { // Connect to the database loadConfig(app) connectToDatabase(app) defer shutdown(app) // Fetch user u, err := app.db.GetUserForAuth(*resetPassUser) if err != nil { log.Error("Get user: %s", err) os.Exit(1) } // Prompt for new password prompt := promptui.Prompt{ Templates: &promptui.PromptTemplates{ Success: "{{ . | bold | faint }}: ", }, Label: "New password", Mask: '*', } newPass, err := prompt.Run() if err != nil { log.Error("%s", err) os.Exit(1) } // Do the update log.Info("Updating...") err = adminResetPassword(app, u, newPass) if err != nil { log.Error("%s", err) os.Exit(1) } log.Info("Success.") os.Exit(0) } else if *migrate { loadConfig(app) connectToDatabase(app) defer shutdown(app) err := migrations.Migrate(migrations.NewDatastore(app.db.DB, app.db.driverName)) if err != nil { log.Error("migrate: %s", err) os.Exit(1) } os.Exit(0) } log.Info("Initializing...") loadConfig(app) hostName = app.cfg.App.Host isSingleUser = app.cfg.App.SingleUser app.cfg.Server.Dev = *debugPtr err := initTemplates(app.cfg) if err != nil { log.Error("load templates: %s", err) os.Exit(1) } // Load keys log.Info("Loading encryption keys...") initKeyPaths(app) 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.Type == driverMySQL && (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 = "writefreely" } connectToDatabase(app) defer shutdown(app) // Test database connection err = app.db.Ping() if err != nil { log.Error("Database ping failed: %s", err) } 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 local timeline, if enabled if app.cfg.App.LocalTimeline { log.Info("Initializing local timeline...") initLocalTimeline(app) } // Handle static files fs := http.FileServer(http.Dir(filepath.Join(app.cfg.Server.StaticParentDir, 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) }() http.Handle("/", r) // Start web application server var bindAddress = app.cfg.Server.Bind if bindAddress == "" { bindAddress = "localhost" } if app.cfg.IsSecureStandalone() { log.Info("Serving redirects on http://%s:80", bindAddress) go func() { err = http.ListenAndServe( fmt.Sprintf("%s:80", bindAddress), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, app.cfg.App.Host, http.StatusMovedPermanently) })) log.Error("Unable to start redirect server: %v", err) }() log.Info("Serving on https://%s:443", bindAddress) log.Info("---") err = http.ListenAndServeTLS( fmt.Sprintf("%s:443", bindAddress), app.cfg.Server.TLSCertPath, app.cfg.Server.TLSKeyPath, nil) } else { log.Info("Serving on http://%s:%d\n", bindAddress, app.cfg.Server.Port) log.Info("---") err = http.ListenAndServe(fmt.Sprintf("%s:%d", bindAddress, app.cfg.Server.Port), nil) } if err != nil { log.Error("Unable to start: %v", err) os.Exit(1) } } func loadConfig(app *app) { log.Info("Loading %s configuration...", app.cfgFile) cfg, err := config.Load(app.cfgFile) if err != nil { log.Error("Unable to load configuration: %v", err) os.Exit(1) } app.cfg = cfg } func connectToDatabase(app *app) { log.Info("Connecting to %s database...", app.cfg.Database.Type) var db *sql.DB var err error if app.cfg.Database.Type == driverMySQL { db, err = sql.Open(app.cfg.Database.Type, fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database, url.QueryEscape(time.Local.String()))) db.SetMaxOpenConns(50) } else if app.cfg.Database.Type == driverSQLite { if !SQLiteEnabled { log.Error("Invalid database type '%s'. Binary wasn't compiled with SQLite3 support.", app.cfg.Database.Type) os.Exit(1) } if app.cfg.Database.FileName == "" { log.Error("SQLite database filename value in config.ini is empty.") os.Exit(1) } db, err = sql.Open("sqlite3_with_regex", app.cfg.Database.FileName+"?parseTime=true&cached=shared") db.SetMaxOpenConns(1) } else { log.Error("Invalid database type '%s'. Only 'mysql' and 'sqlite3' are supported right now.", app.cfg.Database.Type) os.Exit(1) } if err != nil { log.Error("%s", err) os.Exit(1) } app.db = &datastore{db, app.cfg.Database.Type} } func shutdown(app *app) { log.Info("Closing database connection...") app.db.Close() } -func adminCreateUser(app *app, credStr string, isAdmin bool) { +func adminCreateUser(app *app, credStr string, isAdmin bool) error { // Create an admin user with --create-admin creds := strings.Split(credStr, ":") if len(creds) != 2 { - log.Error("usage: writefreely --create-admin username:password") - os.Exit(1) + c := "user" + if isAdmin { + c = "admin" + } + return fmt.Errorf("usage: writefreely --create-%s username:password", c) } loadConfig(app) connectToDatabase(app) defer shutdown(app) // Ensure an admin / first user doesn't already exist firstUser, _ := app.db.GetUserByID(1) if isAdmin { // Abort if trying to create admin user, but one already exists if firstUser != nil { - log.Error("Admin user already exists (%s). Create a regular user with: writefreely --create-user", firstUser.Username) - os.Exit(1) + return fmt.Errorf("Admin user already exists (%s). Create a regular user with: writefreely --create-user", firstUser.Username) } } else { // Abort if trying to create regular user, but no admin exists yet if firstUser == nil { - log.Error("No admin user exists yet. Create an admin first with: writefreely --create-admin") - os.Exit(1) + return fmt.Errorf("No admin user exists yet. Create an admin first with: writefreely --create-admin") } } // Create the user username := creds[0] password := creds[1] // Normalize and validate username desiredUsername := username username = getSlug(username, "") usernameDesc := username if username != desiredUsername { usernameDesc += " (originally: " + desiredUsername + ")" } if !author.IsValidUsername(app.cfg, username) { - log.Error("Username %s is invalid, reserved, or shorter than configured minimum length (%d characters).", usernameDesc, app.cfg.App.MinUsernameLen) - os.Exit(1) + return fmt.Errorf("Username %s is invalid, reserved, or shorter than configured minimum length (%d characters).", usernameDesc, app.cfg.App.MinUsernameLen) } // Hash the password hashedPass, err := auth.HashPass([]byte(password)) if err != nil { - log.Error("Unable to hash password: %v", err) - os.Exit(1) + return fmt.Errorf("Unable to hash password: %v", err) } u := &User{ Username: username, HashedPass: hashedPass, Created: time.Now().Truncate(time.Second).UTC(), } userType := "user" if isAdmin { userType = "admin" } log.Info("Creating %s %s...", userType, usernameDesc) err = app.db.CreateUser(u, desiredUsername) if err != nil { - log.Error("Unable to create user: %s", err) - os.Exit(1) + return fmt.Errorf("Unable to create user: %s", err) } log.Info("Done!") - os.Exit(0) + return nil } -func adminInitDatabase(app *app) { +func adminInitDatabase(app *app) error { schemaFileName := "schema.sql" if app.cfg.Database.Type == driverSQLite { schemaFileName = "sqlite.sql" } schema, err := Asset(schemaFileName) if err != nil { - log.Error("Unable to load schema file: %v", err) - os.Exit(1) + return fmt.Errorf("Unable to load schema file: %v", err) } tblReg := regexp.MustCompile("CREATE TABLE (IF NOT EXISTS )?`([a-z_]+)`") queries := strings.Split(string(schema), ";\n") for _, q := range queries { if strings.TrimSpace(q) == "" { continue } parts := tblReg.FindStringSubmatch(q) if len(parts) >= 3 { log.Info("Creating table %s...", parts[2]) } else { log.Info("Creating table ??? (Weird query) No match in: %v", parts) } _, err = app.db.Exec(q) if err != nil { log.Error("%s", err) } else { log.Info("Created.") } } // Set up migrations table log.Info("Updating appmigrations table...") err = migrations.SetInitialMigrations(migrations.NewDatastore(app.db.DB, app.db.driverName)) if err != nil { - log.Error("Unable to set initial migrations: %v", err) - os.Exit(1) + return fmt.Errorf("Unable to set initial migrations: %v", err) } - log.Info("Done.") - os.Exit(0) + log.Info("Done.") + return nil }