583 lines
15 KiB
Go
583 lines
15 KiB
Go
/*
|
|
* 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"
|
|
"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/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.7.1"
|
|
|
|
// 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() {
|
|
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")
|
|
createSchema := flag.Bool("init-db", false, "Initialize app database")
|
|
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")
|
|
configFile := flag.String("c", "config.ini", "The configuration file to use")
|
|
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)
|
|
}
|
|
|
|
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)
|
|
} else if *createSchema {
|
|
loadConfig(app)
|
|
connectToDatabase(app)
|
|
defer shutdown(app)
|
|
adminInitDatabase(app)
|
|
} else if *createAdmin != "" {
|
|
adminCreateUser(app, *createAdmin, true)
|
|
} else if *createUser != "" {
|
|
adminCreateUser(app, *createUser, false)
|
|
} 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)
|
|
}
|
|
|
|
log.Info("Initializing...")
|
|
|
|
loadConfig(app)
|
|
|
|
hostName = app.cfg.App.Host
|
|
isSingleUser = app.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.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(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) {
|
|
// 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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
} 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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Hash the password
|
|
hashedPass, err := auth.HashPass([]byte(password))
|
|
if err != nil {
|
|
log.Error("Unable to hash password: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
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)
|
|
}
|
|
log.Info("Done!")
|
|
os.Exit(0)
|
|
}
|
|
|
|
func adminInitDatabase(app *app) {
|
|
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)
|
|
}
|
|
|
|
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.")
|
|
}
|
|
}
|
|
os.Exit(0)
|
|
}
|