2018-12-24 18:45:15 +01:00
|
|
|
/*
|
2022-11-11 05:49:16 +01:00
|
|
|
* Copyright © 2018-2021 Musing Studio LLC.
|
2018-12-24 18:45:15 +01:00
|
|
|
*
|
|
|
|
* 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.
|
|
|
|
*/
|
2018-12-31 07:05:26 +01:00
|
|
|
|
2018-10-15 20:44:15 +02:00
|
|
|
package writefreely
|
|
|
|
|
|
|
|
import (
|
2019-07-21 02:49:20 +02:00
|
|
|
"crypto/tls"
|
2018-10-17 04:31:27 +02:00
|
|
|
"database/sql"
|
2022-12-25 00:59:19 +01:00
|
|
|
_ "embed"
|
2018-10-15 20:44:15 +02:00
|
|
|
"fmt"
|
2018-11-10 03:14:22 +01:00
|
|
|
"html/template"
|
2019-06-14 00:50:23 +02:00
|
|
|
"io/ioutil"
|
2022-04-27 15:03:45 +02:00
|
|
|
"net"
|
2018-10-15 20:44:15 +02:00
|
|
|
"net/http"
|
2018-11-14 23:47:58 +01:00
|
|
|
"net/url"
|
2018-10-15 20:44:15 +02:00
|
|
|
"os"
|
|
|
|
"os/signal"
|
2019-01-19 00:57:04 +01:00
|
|
|
"path/filepath"
|
2018-11-08 06:11:42 +01:00
|
|
|
"regexp"
|
2018-11-13 19:04:52 +01:00
|
|
|
"strings"
|
2018-10-15 20:44:15 +02:00
|
|
|
"syscall"
|
2018-11-10 04:16:13 +01:00
|
|
|
"time"
|
2018-10-15 20:44:15 +02:00
|
|
|
|
|
|
|
"github.com/gorilla/mux"
|
2018-11-08 07:31:01 +01:00
|
|
|
"github.com/gorilla/schema"
|
2018-10-15 20:44:15 +02:00
|
|
|
"github.com/gorilla/sessions"
|
2018-11-14 21:03:22 +01:00
|
|
|
"github.com/manifoldco/promptui"
|
2021-06-25 17:16:03 +02:00
|
|
|
stripmd "github.com/writeas/go-strip-markdown/v2"
|
2019-05-13 02:11:53 +02:00
|
|
|
"github.com/writeas/impart"
|
2018-12-06 03:41:51 +01:00
|
|
|
"github.com/writeas/web-core/auth"
|
2018-11-08 07:31:01 +01:00
|
|
|
"github.com/writeas/web-core/converter"
|
2018-10-15 20:44:15 +02:00
|
|
|
"github.com/writeas/web-core/log"
|
2022-12-25 00:59:19 +01:00
|
|
|
"golang.org/x/crypto/acme/autocert"
|
|
|
|
|
2021-04-06 23:24:07 +02:00
|
|
|
"github.com/writefreely/writefreely/author"
|
|
|
|
"github.com/writefreely/writefreely/config"
|
|
|
|
"github.com/writefreely/writefreely/key"
|
|
|
|
"github.com/writefreely/writefreely/migrations"
|
|
|
|
"github.com/writefreely/writefreely/page"
|
2018-10-15 20:44:15 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
2019-01-19 00:57:04 +01:00
|
|
|
staticDir = "static"
|
2018-11-08 07:31:01 +01:00
|
|
|
assumedTitleLen = 80
|
|
|
|
postsPerPage = 10
|
2018-10-18 00:57:37 +02:00
|
|
|
|
2018-11-08 19:37:42 +01:00
|
|
|
serverSoftware = "WriteFreely"
|
2018-10-18 00:57:37 +02:00
|
|
|
softwareURL = "https://writefreely.org"
|
2018-10-15 20:44:15 +02:00
|
|
|
)
|
|
|
|
|
2018-11-08 04:13:16 +01:00
|
|
|
var (
|
|
|
|
debugging bool
|
2018-11-08 07:31:01 +01:00
|
|
|
|
2018-11-26 14:37:06 +01:00
|
|
|
// Software version can be set from git env using -ldflags
|
2022-07-22 06:22:26 +02:00
|
|
|
softwareVer = "0.13.2"
|
2018-11-26 14:37:06 +01:00
|
|
|
|
2018-11-08 07:31:01 +01:00
|
|
|
// DEPRECATED VARS
|
|
|
|
isSingleUser bool
|
2018-11-08 04:13:16 +01:00
|
|
|
)
|
|
|
|
|
2019-05-12 22:55:30 +02:00
|
|
|
// App holds data and configuration for an individual WriteFreely instance.
|
|
|
|
type App struct {
|
2018-10-15 20:44:15 +02:00
|
|
|
router *mux.Router
|
2019-06-14 00:22:18 +02:00
|
|
|
shttp *http.ServeMux
|
2018-10-17 04:31:27 +02:00
|
|
|
db *datastore
|
2018-10-15 20:44:15 +02:00
|
|
|
cfg *config.Config
|
2018-12-08 23:49:19 +01:00
|
|
|
cfgFile string
|
2019-06-13 16:14:35 +02:00
|
|
|
keys *key.Keychain
|
2019-12-19 17:48:04 +01:00
|
|
|
sessionStore sessions.Store
|
2018-11-08 07:31:01 +01:00
|
|
|
formDecoder *schema.Decoder
|
2019-08-30 00:05:59 +02:00
|
|
|
updates *updatesCache
|
2018-12-10 22:02:42 +01:00
|
|
|
|
|
|
|
timeline *localTimeline
|
2018-10-15 20:44:15 +02:00
|
|
|
}
|
|
|
|
|
2019-06-14 00:50:23 +02:00
|
|
|
// DB returns the App's datastore
|
|
|
|
func (app *App) DB() *datastore {
|
|
|
|
return app.db
|
|
|
|
}
|
|
|
|
|
|
|
|
// Router returns the App's router
|
|
|
|
func (app *App) Router() *mux.Router {
|
|
|
|
return app.router
|
|
|
|
}
|
|
|
|
|
|
|
|
// Config returns the App's current configuration.
|
|
|
|
func (app *App) Config() *config.Config {
|
|
|
|
return app.cfg
|
|
|
|
}
|
|
|
|
|
|
|
|
// SetConfig updates the App's Config to the given value.
|
|
|
|
func (app *App) SetConfig(cfg *config.Config) {
|
|
|
|
app.cfg = cfg
|
|
|
|
}
|
|
|
|
|
|
|
|
// SetKeys updates the App's Keychain to the given value.
|
2019-06-13 16:14:35 +02:00
|
|
|
func (app *App) SetKeys(k *key.Keychain) {
|
|
|
|
app.keys = k
|
|
|
|
}
|
|
|
|
|
2019-12-19 17:48:04 +01:00
|
|
|
func (app *App) SessionStore() sessions.Store {
|
|
|
|
return app.sessionStore
|
|
|
|
}
|
|
|
|
|
|
|
|
func (app *App) SetSessionStore(s sessions.Store) {
|
|
|
|
app.sessionStore = s
|
|
|
|
}
|
|
|
|
|
2019-06-14 00:50:23 +02:00
|
|
|
// Apper is the interface for getting data into and out of a WriteFreely
|
|
|
|
// instance (or "App").
|
|
|
|
//
|
|
|
|
// App returns the App for the current instance.
|
|
|
|
//
|
|
|
|
// LoadConfig reads an app configuration into the App, returning any error
|
|
|
|
// encountered.
|
|
|
|
//
|
|
|
|
// SaveConfig persists the current App configuration.
|
|
|
|
//
|
|
|
|
// LoadKeys reads the App's encryption keys and loads them into its
|
|
|
|
// key.Keychain.
|
|
|
|
type Apper interface {
|
|
|
|
App() *App
|
|
|
|
|
|
|
|
LoadConfig() error
|
|
|
|
SaveConfig(*config.Config) error
|
|
|
|
|
|
|
|
LoadKeys() error
|
2019-08-01 22:12:22 +02:00
|
|
|
|
|
|
|
ReqLog(r *http.Request, status int, timeSince time.Duration) string
|
2019-06-14 00:50:23 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// App returns the App
|
|
|
|
func (app *App) App() *App {
|
|
|
|
return app
|
|
|
|
}
|
|
|
|
|
|
|
|
// LoadConfig loads and parses a config file.
|
|
|
|
func (app *App) LoadConfig() error {
|
|
|
|
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)
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
app.cfg = cfg
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// SaveConfig saves the given Config to disk -- namely, to the App's cfgFile.
|
|
|
|
func (app *App) SaveConfig(c *config.Config) error {
|
|
|
|
return config.Save(c, app.cfgFile)
|
|
|
|
}
|
|
|
|
|
|
|
|
// LoadKeys reads all needed keys from disk into the App. In order to use the
|
|
|
|
// configured `Server.KeysParentDir`, you must call initKeyPaths(App) before
|
|
|
|
// this.
|
|
|
|
func (app *App) LoadKeys() error {
|
|
|
|
var err error
|
|
|
|
app.keys = &key.Keychain{}
|
|
|
|
|
|
|
|
if debugging {
|
|
|
|
log.Info(" %s", emailKeyPath)
|
|
|
|
}
|
2021-04-22 18:41:54 +02:00
|
|
|
|
|
|
|
executable, err := os.Executable()
|
|
|
|
if err != nil {
|
|
|
|
executable = "writefreely"
|
|
|
|
} else {
|
|
|
|
executable = filepath.Base(executable)
|
|
|
|
}
|
|
|
|
|
2019-06-14 00:50:23 +02:00
|
|
|
app.keys.EmailKey, err = ioutil.ReadFile(emailKeyPath)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
if debugging {
|
|
|
|
log.Info(" %s", cookieAuthKeyPath)
|
|
|
|
}
|
|
|
|
app.keys.CookieAuthKey, err = ioutil.ReadFile(cookieAuthKeyPath)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
if debugging {
|
|
|
|
log.Info(" %s", cookieKeyPath)
|
|
|
|
}
|
|
|
|
app.keys.CookieKey, err = ioutil.ReadFile(cookieKeyPath)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2021-04-22 18:41:54 +02:00
|
|
|
if debugging {
|
|
|
|
log.Info(" %s", csrfKeyPath)
|
|
|
|
}
|
|
|
|
app.keys.CSRFKey, err = ioutil.ReadFile(csrfKeyPath)
|
|
|
|
if err != nil {
|
|
|
|
if os.IsNotExist(err) {
|
|
|
|
log.Error(`Missing key: %s.
|
|
|
|
|
|
|
|
Run this command to generate missing keys:
|
|
|
|
%s keys generate
|
|
|
|
|
|
|
|
`, csrfKeyPath, executable)
|
|
|
|
}
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2019-06-14 00:50:23 +02:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2019-08-01 22:12:22 +02:00
|
|
|
func (app *App) ReqLog(r *http.Request, status int, timeSince time.Duration) string {
|
|
|
|
return fmt.Sprintf("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, timeSince, r.UserAgent())
|
|
|
|
}
|
|
|
|
|
2019-08-09 17:32:53 +02:00
|
|
|
// handleViewHome shows page at root path. It checks the configuration and
|
|
|
|
// authentication state to show the correct page.
|
2019-05-12 22:55:30 +02:00
|
|
|
func handleViewHome(app *App, w http.ResponseWriter, r *http.Request) error {
|
2018-11-08 06:11:42 +01:00
|
|
|
if app.cfg.App.SingleUser {
|
|
|
|
// Render blog index
|
|
|
|
return handleViewCollection(app, w, r)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Multi-user instance
|
2019-06-27 22:38:24 +02:00
|
|
|
forceLanding := r.FormValue("landing") == "1"
|
|
|
|
if !forceLanding {
|
|
|
|
// Show correct page based on user auth status and configured landing path
|
|
|
|
u := getUserSession(app, r)
|
2019-08-09 20:57:09 +02:00
|
|
|
|
|
|
|
if app.cfg.App.Chorus {
|
|
|
|
// This instance is focused on reading, so show Reader on home route if not
|
|
|
|
// private or a private-instance user is logged in.
|
|
|
|
if !app.cfg.App.Private || u != nil {
|
|
|
|
return viewLocalTimeline(app, w, r)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-06-27 22:38:24 +02:00
|
|
|
if u != nil {
|
|
|
|
// User is logged in, so show the Pad
|
|
|
|
return handleViewPad(app, w, r)
|
|
|
|
}
|
|
|
|
|
2020-03-02 23:32:04 +01:00
|
|
|
if app.cfg.App.Private {
|
|
|
|
return viewLogin(app, w, r)
|
|
|
|
}
|
|
|
|
|
2019-06-27 22:38:24 +02:00
|
|
|
if land := app.cfg.App.LandingPath(); land != "/" {
|
|
|
|
return impart.HTTPError{http.StatusFound, land}
|
|
|
|
}
|
2019-05-13 02:11:53 +02:00
|
|
|
}
|
|
|
|
|
2019-08-09 18:00:46 +02:00
|
|
|
return handleViewLanding(app, w, r)
|
|
|
|
}
|
|
|
|
|
|
|
|
func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error {
|
|
|
|
forceLanding := r.FormValue("landing") == "1"
|
|
|
|
|
2018-11-10 03:14:22 +01:00
|
|
|
p := struct {
|
|
|
|
page.StaticPage
|
2020-08-19 19:31:07 +02:00
|
|
|
*OAuthButtons
|
2018-11-10 03:14:22 +01:00
|
|
|
Flashes []template.HTML
|
2019-06-27 23:06:37 +02:00
|
|
|
Banner template.HTML
|
|
|
|
Content template.HTML
|
2019-06-27 22:38:24 +02:00
|
|
|
|
|
|
|
ForcedLanding bool
|
2018-11-10 03:14:22 +01:00
|
|
|
}{
|
2020-08-19 19:31:07 +02:00
|
|
|
StaticPage: pageForReq(app, r),
|
|
|
|
OAuthButtons: NewOAuthButtons(app.Config()),
|
|
|
|
ForcedLanding: forceLanding,
|
2018-11-10 03:14:22 +01:00
|
|
|
}
|
|
|
|
|
2019-06-27 23:06:37 +02:00
|
|
|
banner, err := getLandingBanner(app)
|
|
|
|
if err != nil {
|
|
|
|
log.Error("unable to get landing banner: %v", err)
|
|
|
|
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get banner: %v", err)}
|
|
|
|
}
|
2019-08-07 15:26:07 +02:00
|
|
|
p.Banner = template.HTML(applyMarkdown([]byte(banner.Content), "", app.cfg))
|
2019-06-27 23:06:37 +02:00
|
|
|
|
2019-06-28 04:22:21 +02:00
|
|
|
content, err := getLandingBody(app)
|
2019-06-27 23:06:37 +02:00
|
|
|
if err != nil {
|
|
|
|
log.Error("unable to get landing content: %v", err)
|
|
|
|
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get content: %v", err)}
|
2018-11-10 03:14:22 +01:00
|
|
|
}
|
2019-08-07 15:26:07 +02:00
|
|
|
p.Content = template.HTML(applyMarkdown([]byte(content.Content), "", app.cfg))
|
2018-11-10 03:14:22 +01:00
|
|
|
|
|
|
|
// 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))
|
|
|
|
}
|
|
|
|
|
2018-11-08 06:11:42 +01:00
|
|
|
// Show landing page
|
2018-11-10 03:14:22 +01:00
|
|
|
return renderPage(w, "landing.tmpl", p)
|
2018-11-08 06:11:42 +01:00
|
|
|
}
|
|
|
|
|
2019-05-12 22:55:30 +02:00
|
|
|
func handleTemplatedPage(app *App, w http.ResponseWriter, r *http.Request, t *template.Template) error {
|
2018-11-19 03:58:50 +01:00
|
|
|
p := struct {
|
|
|
|
page.StaticPage
|
2019-04-11 19:56:07 +02:00
|
|
|
ContentTitle string
|
2018-11-21 21:04:47 +01:00
|
|
|
Content template.HTML
|
|
|
|
PlainContent string
|
|
|
|
Updated string
|
2018-11-21 20:05:44 +01:00
|
|
|
|
|
|
|
AboutStats *InstanceStats
|
2018-11-19 03:58:50 +01:00
|
|
|
}{
|
|
|
|
StaticPage: pageForReq(app, r),
|
|
|
|
}
|
|
|
|
if r.URL.Path == "/about" || r.URL.Path == "/privacy" {
|
2019-04-06 19:23:22 +02:00
|
|
|
var c *instanceContent
|
2018-11-19 03:58:50 +01:00
|
|
|
var err error
|
|
|
|
|
|
|
|
if r.URL.Path == "/about" {
|
|
|
|
c, err = getAboutPage(app)
|
2018-11-21 20:05:44 +01:00
|
|
|
|
|
|
|
// Fetch stats
|
|
|
|
p.AboutStats = &InstanceStats{}
|
|
|
|
p.AboutStats.NumPosts, _ = app.db.GetTotalPosts()
|
|
|
|
p.AboutStats.NumBlogs, _ = app.db.GetTotalCollections()
|
2018-11-19 03:58:50 +01:00
|
|
|
} else {
|
2019-04-06 19:23:22 +02:00
|
|
|
c, err = getPrivacyPage(app)
|
2018-11-19 03:58:50 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2019-04-11 19:56:07 +02:00
|
|
|
p.ContentTitle = c.Title.String
|
2019-08-07 15:26:07 +02:00
|
|
|
p.Content = template.HTML(applyMarkdown([]byte(c.Content), "", app.cfg))
|
2019-04-06 19:23:22 +02:00
|
|
|
p.PlainContent = shortPostDescription(stripmd.Strip(c.Content))
|
|
|
|
if !c.Updated.IsZero() {
|
|
|
|
p.Updated = c.Updated.Format("January 2, 2006")
|
2018-11-19 03:58:50 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Serve templated page
|
|
|
|
err := t.ExecuteTemplate(w, "base", p)
|
|
|
|
if err != nil {
|
|
|
|
log.Error("Unable to render page: %v", err)
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2019-05-12 22:55:30 +02:00
|
|
|
func pageForReq(app *App, r *http.Request) page.StaticPage {
|
2018-11-08 05:50:50 +01:00
|
|
|
p := page.StaticPage{
|
|
|
|
AppCfg: app.cfg.App,
|
|
|
|
Path: r.URL.Path,
|
2018-11-20 18:14:02 +01:00
|
|
|
Version: "v" + softwareVer,
|
2018-11-08 05:50:50 +01:00
|
|
|
}
|
|
|
|
|
2022-02-01 04:18:52 +01:00
|
|
|
// Use custom style, if file exists
|
|
|
|
if _, err := os.Stat(filepath.Join(staticDir, "local", "custom.css")); err == nil {
|
|
|
|
p.CustomCSS = true
|
|
|
|
}
|
|
|
|
|
2018-11-08 05:50:50 +01:00
|
|
|
// 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
|
2019-08-07 15:00:16 +02:00
|
|
|
p.IsAdmin = u != nil && u.IsAdmin()
|
|
|
|
p.CanInvite = canUserInvite(app.cfg, p.IsAdmin)
|
2018-11-08 05:50:50 +01:00
|
|
|
}
|
|
|
|
}
|
2019-06-17 02:29:31 +02:00
|
|
|
p.CanViewReader = !app.cfg.App.Private || u != nil
|
2018-11-08 05:50:50 +01:00
|
|
|
|
|
|
|
return p
|
|
|
|
}
|
|
|
|
|
2018-11-08 06:11:42 +01:00
|
|
|
var fileRegex = regexp.MustCompile("/([^/]*\\.[^/]*)$")
|
2018-10-15 20:44:15 +02:00
|
|
|
|
2019-06-14 00:50:23 +02:00
|
|
|
// Initialize loads the app configuration and initializes templates, keys,
|
|
|
|
// session, route handlers, and the database connection.
|
|
|
|
func Initialize(apper Apper, debug bool) (*App, error) {
|
2019-05-10 17:40:35 +02:00
|
|
|
debugging = debug
|
2018-10-16 22:57:55 +02:00
|
|
|
|
2019-06-14 00:50:23 +02:00
|
|
|
apper.LoadConfig()
|
2018-11-08 04:13:16 +01:00
|
|
|
|
2019-06-14 00:50:23 +02:00
|
|
|
// Load templates
|
|
|
|
err := InitTemplates(apper.App().Config())
|
2019-01-19 01:17:10 +01:00
|
|
|
if err != nil {
|
2019-06-14 00:50:23 +02:00
|
|
|
return nil, fmt.Errorf("load templates: %s", err)
|
2019-01-19 01:17:10 +01:00
|
|
|
}
|
2018-11-08 05:50:50 +01:00
|
|
|
|
2019-06-14 00:50:23 +02:00
|
|
|
// Load keys and set up session
|
|
|
|
initKeyPaths(apper.App()) // TODO: find a better way to do this, since it's unneeded in all Apper implementations
|
|
|
|
err = InitKeys(apper)
|
2018-10-15 20:44:15 +02:00
|
|
|
if err != nil {
|
2019-06-14 00:50:23 +02:00
|
|
|
return nil, fmt.Errorf("init keys: %s", err)
|
2018-10-17 04:31:27 +02:00
|
|
|
}
|
2019-08-30 00:05:59 +02:00
|
|
|
apper.App().InitUpdates()
|
|
|
|
|
2019-06-14 00:50:23 +02:00
|
|
|
apper.App().InitSession()
|
2018-10-17 04:31:27 +02:00
|
|
|
|
2019-06-14 00:50:23 +02:00
|
|
|
apper.App().InitDecoder()
|
2018-10-17 04:31:27 +02:00
|
|
|
|
2019-06-14 00:50:23 +02:00
|
|
|
err = ConnectToDatabase(apper.App())
|
2018-12-14 01:15:09 +01:00
|
|
|
if err != nil {
|
2019-06-14 00:50:23 +02:00
|
|
|
return nil, fmt.Errorf("connect to DB: %s", err)
|
2018-12-14 01:15:09 +01:00
|
|
|
}
|
|
|
|
|
2021-03-08 18:50:08 +01:00
|
|
|
initActivityPub(apper.App())
|
|
|
|
|
2018-12-10 22:02:42 +01:00
|
|
|
// Handle local timeline, if enabled
|
2019-06-14 00:50:23 +02:00
|
|
|
if apper.App().cfg.App.LocalTimeline {
|
2018-12-10 22:02:42 +01:00
|
|
|
log.Info("Initializing local timeline...")
|
2019-06-14 00:50:23 +02:00
|
|
|
initLocalTimeline(apper.App())
|
2018-12-10 22:02:42 +01:00
|
|
|
}
|
|
|
|
|
2019-06-14 00:50:23 +02:00
|
|
|
return apper.App(), nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func Serve(app *App, r *mux.Router) {
|
|
|
|
log.Info("Going to serve...")
|
|
|
|
|
|
|
|
isSingleUser = app.cfg.App.SingleUser
|
|
|
|
app.cfg.Server.Dev = debugging
|
2018-10-15 20:44:15 +02:00
|
|
|
|
|
|
|
// 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)
|
|
|
|
}()
|
|
|
|
|
2020-03-02 02:12:47 +01:00
|
|
|
// Start gopher server
|
2020-07-23 17:11:11 +02:00
|
|
|
if app.cfg.Server.GopherPort > 0 && !app.cfg.App.Private {
|
2020-03-02 02:12:47 +01:00
|
|
|
go initGopher(app)
|
|
|
|
}
|
|
|
|
|
2018-11-22 00:26:19 +01:00
|
|
|
// Start web application server
|
2018-11-26 16:50:36 +01:00
|
|
|
var bindAddress = app.cfg.Server.Bind
|
|
|
|
if bindAddress == "" {
|
|
|
|
bindAddress = "localhost"
|
|
|
|
}
|
2019-06-14 00:50:23 +02:00
|
|
|
var err error
|
2018-11-22 00:26:19 +01:00
|
|
|
if app.cfg.IsSecureStandalone() {
|
2019-07-21 02:49:20 +02:00
|
|
|
if app.cfg.Server.Autocert {
|
|
|
|
m := &autocert.Manager{
|
2019-07-21 03:34:58 +02:00
|
|
|
Prompt: autocert.AcceptTOS,
|
|
|
|
Cache: autocert.DirCache(app.cfg.Server.TLSCertPath),
|
|
|
|
}
|
|
|
|
host, err := url.Parse(app.cfg.App.Host)
|
|
|
|
if err != nil {
|
|
|
|
log.Error("[WARNING] Unable to parse configured host! %s", err)
|
|
|
|
log.Error(`[WARNING] ALL hosts are allowed, which can open you to an attack where
|
|
|
|
clients connect to a server by IP address and pretend to be asking for an
|
|
|
|
incorrect host name, and cause you to reach the CA's rate limit for certificate
|
|
|
|
requests. We recommend supplying a valid host name.`)
|
|
|
|
log.Info("Using autocert on ANY host")
|
|
|
|
} else {
|
|
|
|
log.Info("Using autocert on host %s", host.Host)
|
|
|
|
m.HostPolicy = autocert.HostWhitelist(host.Host)
|
2019-07-21 02:49:20 +02:00
|
|
|
}
|
|
|
|
s := &http.Server{
|
|
|
|
Addr: ":https",
|
|
|
|
Handler: r,
|
|
|
|
TLSConfig: &tls.Config{
|
|
|
|
GetCertificate: m.GetCertificate,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
s.SetKeepAlivesEnabled(false)
|
|
|
|
|
2019-07-21 03:38:02 +02:00
|
|
|
go func() {
|
|
|
|
log.Info("Serving redirects on http://%s:80", bindAddress)
|
|
|
|
err = http.ListenAndServe(":80", m.HTTPHandler(nil))
|
|
|
|
log.Error("Unable to start redirect server: %v", err)
|
|
|
|
}()
|
|
|
|
|
|
|
|
log.Info("Serving on https://%s:443", bindAddress)
|
2019-07-21 02:49:20 +02:00
|
|
|
log.Info("---")
|
|
|
|
err = s.ListenAndServeTLS("", "")
|
|
|
|
} else {
|
2019-07-21 03:38:02 +02:00
|
|
|
go func() {
|
|
|
|
log.Info("Serving redirects on http://%s:80", bindAddress)
|
|
|
|
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)
|
2019-07-21 02:49:20 +02:00
|
|
|
log.Info("Using manual certificates")
|
|
|
|
log.Info("---")
|
|
|
|
err = http.ListenAndServeTLS(fmt.Sprintf("%s:443", bindAddress), app.cfg.Server.TLSCertPath, app.cfg.Server.TLSKeyPath, r)
|
|
|
|
}
|
2018-11-22 00:26:19 +01:00
|
|
|
} else {
|
2022-12-26 19:18:45 +01:00
|
|
|
network := "tcp"
|
|
|
|
protocol := "http"
|
2022-04-27 15:03:45 +02:00
|
|
|
if strings.HasPrefix(bindAddress, "/") {
|
|
|
|
network = "unix"
|
|
|
|
protocol = "http+unix"
|
|
|
|
|
|
|
|
// old sockets will remain after server closes;
|
|
|
|
// we need to delete them in order to open new ones
|
2022-12-26 19:17:56 +01:00
|
|
|
err = os.Remove(bindAddress)
|
|
|
|
if err != nil && !os.IsNotExist(err) {
|
|
|
|
log.Error("%s already exists but could not be removed: %v", bindAddress, err)
|
2022-04-27 15:03:45 +02:00
|
|
|
os.Exit(1)
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
bindAddress = fmt.Sprintf("%s:%d", bindAddress, app.cfg.Server.Port)
|
|
|
|
}
|
|
|
|
|
|
|
|
log.Info("Serving on %s://%s", protocol, bindAddress)
|
2018-11-22 00:26:19 +01:00
|
|
|
log.Info("---")
|
2022-12-26 19:17:56 +01:00
|
|
|
listener, err := net.Listen(network, bindAddress)
|
|
|
|
if err != nil {
|
|
|
|
log.Error("Could not bind to address: %v", err)
|
2022-04-27 15:03:45 +02:00
|
|
|
os.Exit(1)
|
|
|
|
}
|
|
|
|
|
|
|
|
if network == "unix" {
|
2022-12-26 19:17:56 +01:00
|
|
|
err = os.Chmod(bindAddress, 0o666)
|
|
|
|
if err != nil {
|
|
|
|
log.Error("Could not update socket permissions: %v", err)
|
2022-04-27 15:03:45 +02:00
|
|
|
os.Exit(1)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
defer listener.Close()
|
|
|
|
err = http.Serve(listener, r)
|
2018-11-22 00:26:19 +01:00
|
|
|
}
|
2018-11-11 02:41:35 +01:00
|
|
|
if err != nil {
|
|
|
|
log.Error("Unable to start: %v", err)
|
|
|
|
os.Exit(1)
|
|
|
|
}
|
2018-10-15 20:44:15 +02:00
|
|
|
}
|
|
|
|
|
2019-06-14 00:50:23 +02:00
|
|
|
func (app *App) InitDecoder() {
|
|
|
|
// TODO: do this at the package level, instead of the App level
|
|
|
|
// Initialize modules
|
|
|
|
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)
|
|
|
|
}
|
|
|
|
|
|
|
|
// ConnectToDatabase validates and connects to the configured database, then
|
|
|
|
// tests the connection.
|
|
|
|
func ConnectToDatabase(app *App) error {
|
|
|
|
// Check database configuration
|
|
|
|
if app.cfg.Database.Type == driverMySQL && (app.cfg.Database.User == "" || app.cfg.Database.Password == "") {
|
|
|
|
return fmt.Errorf("Database user or password not set.")
|
|
|
|
}
|
|
|
|
if app.cfg.Database.Host == "" {
|
|
|
|
app.cfg.Database.Host = "localhost"
|
|
|
|
}
|
|
|
|
if app.cfg.Database.Database == "" {
|
|
|
|
app.cfg.Database.Database = "writefreely"
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO: check err
|
|
|
|
connectToDatabase(app)
|
|
|
|
|
|
|
|
// Test database connection
|
|
|
|
err := app.db.Ping()
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("Database ping failed: %s", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2019-08-10 18:02:38 +02:00
|
|
|
// FormatVersion constructs the version string for the application
|
|
|
|
func FormatVersion() string {
|
|
|
|
return serverSoftware + " " + softwareVer
|
|
|
|
}
|
|
|
|
|
2019-05-10 17:40:35 +02:00
|
|
|
// OutputVersion prints out the version of the application.
|
|
|
|
func OutputVersion() {
|
2019-08-10 18:02:38 +02:00
|
|
|
fmt.Println(FormatVersion())
|
2019-05-10 17:40:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// NewApp creates a new app instance.
|
2019-05-12 22:55:30 +02:00
|
|
|
func NewApp(cfgFile string) *App {
|
|
|
|
return &App{
|
2019-05-10 17:40:35 +02:00
|
|
|
cfgFile: cfgFile,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// CreateConfig creates a default configuration and saves it to the app's cfgFile.
|
2019-05-12 22:55:30 +02:00
|
|
|
func CreateConfig(app *App) error {
|
2019-05-10 17:40:35 +02:00
|
|
|
log.Info("Creating configuration...")
|
|
|
|
c := config.New()
|
|
|
|
log.Info("Saving configuration %s...", app.cfgFile)
|
|
|
|
err := config.Save(c, app.cfgFile)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("Unable to save configuration: %v", err)
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// DoConfig runs the interactive configuration process.
|
2019-06-21 11:07:01 +02:00
|
|
|
func DoConfig(app *App, configSections string) {
|
|
|
|
if configSections == "" {
|
|
|
|
configSections = "server db app"
|
|
|
|
}
|
|
|
|
// let's check there aren't any garbage in the list
|
|
|
|
configSectionsArray := strings.Split(configSections, " ")
|
|
|
|
for _, element := range configSectionsArray {
|
|
|
|
if element != "server" && element != "db" && element != "app" {
|
|
|
|
log.Error("Invalid argument to --sections. Valid arguments are only \"server\", \"db\" and \"app\"")
|
|
|
|
os.Exit(1)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
d, err := config.Configure(app.cfgFile, configSections)
|
2019-05-10 17:40:35 +02:00
|
|
|
if err != nil {
|
|
|
|
log.Error("Unable to configure: %v", err)
|
|
|
|
os.Exit(1)
|
|
|
|
}
|
2019-06-20 15:04:52 +02:00
|
|
|
app.cfg = d.Config
|
|
|
|
connectToDatabase(app)
|
|
|
|
defer shutdown(app)
|
|
|
|
|
|
|
|
if !app.db.DatabaseInitialized() {
|
|
|
|
err = adminInitDatabase(app)
|
|
|
|
if err != nil {
|
|
|
|
log.Error(err.Error())
|
|
|
|
os.Exit(1)
|
2019-05-10 17:40:35 +02:00
|
|
|
}
|
2019-06-20 15:04:52 +02:00
|
|
|
} else {
|
|
|
|
log.Info("Database already initialized.")
|
|
|
|
}
|
2019-05-10 17:40:35 +02:00
|
|
|
|
2019-06-20 15:04:52 +02:00
|
|
|
if d.User != nil {
|
2019-05-10 17:40:35 +02:00
|
|
|
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)
|
2021-06-07 20:53:22 +02:00
|
|
|
err = app.db.CreateUser(app.cfg, u, app.cfg.App.SiteName, "")
|
2019-05-10 17:40:35 +02:00
|
|
|
if err != nil {
|
|
|
|
log.Error("Unable to create user: %s", err)
|
|
|
|
os.Exit(1)
|
|
|
|
}
|
|
|
|
log.Info("Done!")
|
|
|
|
}
|
|
|
|
os.Exit(0)
|
|
|
|
}
|
|
|
|
|
2019-06-14 00:50:23 +02:00
|
|
|
// GenerateKeyFiles creates app encryption keys and saves them into the configured KeysParentDir.
|
|
|
|
func GenerateKeyFiles(app *App) error {
|
2019-05-10 17:40:35 +02:00
|
|
|
// Read keys path from config
|
2019-06-14 00:50:23 +02:00
|
|
|
app.LoadConfig()
|
2019-05-10 17:40:35 +02:00
|
|
|
|
|
|
|
// 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 {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Generate keys
|
|
|
|
initKeyPaths(app)
|
2019-06-15 01:11:03 +02:00
|
|
|
// TODO: use something like https://github.com/hashicorp/go-multierror to return errors
|
2019-05-10 17:40:35 +02:00
|
|
|
var keyErrs error
|
|
|
|
err := generateKey(emailKeyPath)
|
|
|
|
if err != nil {
|
|
|
|
keyErrs = err
|
|
|
|
}
|
|
|
|
err = generateKey(cookieAuthKeyPath)
|
|
|
|
if err != nil {
|
|
|
|
keyErrs = err
|
|
|
|
}
|
|
|
|
err = generateKey(cookieKeyPath)
|
|
|
|
if err != nil {
|
|
|
|
keyErrs = err
|
|
|
|
}
|
2021-04-22 18:41:54 +02:00
|
|
|
err = generateKey(csrfKeyPath)
|
|
|
|
if err != nil {
|
|
|
|
keyErrs = err
|
|
|
|
}
|
2019-05-10 17:40:35 +02:00
|
|
|
|
|
|
|
return keyErrs
|
|
|
|
}
|
|
|
|
|
|
|
|
// CreateSchema creates all database tables needed for the application.
|
2019-06-14 00:50:23 +02:00
|
|
|
func CreateSchema(apper Apper) error {
|
|
|
|
apper.LoadConfig()
|
|
|
|
connectToDatabase(apper.App())
|
|
|
|
defer shutdown(apper.App())
|
|
|
|
err := adminInitDatabase(apper.App())
|
2019-05-10 17:40:35 +02:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Migrate runs all necessary database migrations.
|
2019-07-03 20:39:05 +02:00
|
|
|
func Migrate(apper Apper) error {
|
|
|
|
apper.LoadConfig()
|
|
|
|
connectToDatabase(apper.App())
|
|
|
|
defer shutdown(apper.App())
|
2019-05-10 17:40:35 +02:00
|
|
|
|
2019-07-03 20:39:05 +02:00
|
|
|
err := migrations.Migrate(migrations.NewDatastore(apper.App().db.DB, apper.App().db.driverName))
|
2019-05-10 17:40:35 +02:00
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("migrate: %s", err)
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// ResetPassword runs the interactive password reset process.
|
2019-07-03 20:39:43 +02:00
|
|
|
func ResetPassword(apper Apper, username string) error {
|
2019-05-10 17:40:35 +02:00
|
|
|
// Connect to the database
|
2019-07-03 20:39:43 +02:00
|
|
|
apper.LoadConfig()
|
|
|
|
connectToDatabase(apper.App())
|
|
|
|
defer shutdown(apper.App())
|
2019-05-10 17:40:35 +02:00
|
|
|
|
|
|
|
// Fetch user
|
2019-07-03 20:39:43 +02:00
|
|
|
u, err := apper.App().db.GetUserForAuth(username)
|
2019-05-10 17:40:35 +02:00
|
|
|
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...")
|
2019-07-03 20:39:43 +02:00
|
|
|
err = adminResetPassword(apper.App(), u, newPass)
|
|