diff --git a/.gitignore b/.gitignore
index 95ad31b..3d021d7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,9 @@
*~
*.swp
*.swo
build
-config.ini
+*.ini
*.db
bindata.go
diff --git a/AUTHORS.md b/AUTHORS.md
index bea1e4d..c9f40e6 100644
--- a/AUTHORS.md
+++ b/AUTHORS.md
@@ -1,10 +1,11 @@
# WriteFreely Contributors
WriteFreely is built by [Matt Baer](https://github.com/thebaer), with contributions from:
* [Jean-Francois Arseneau](https://github.com/TheJF)
* [Ben Overmyer](https://github.com/BenOvermyer)
* [Marcel van der Boom](https://github.com/mrvdb)
* [Brad Koehn](https://github.com/koehn)
* [kaiyou](https://github.com/kaiyou)
* [Aaron Ogle](https://github.com/geekgonecrazy)
+* [Norman](https://github.com/nkoehring)
diff --git a/README.md b/README.md
index 00778d8..bde2d7d 100644
--- a/README.md
+++ b/README.md
@@ -1,147 +1,157 @@
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.
-**[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)
+[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
* 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) Import the schema with:
+# 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
```
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.
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 192628b..202b3e8 100644
--- a/app.go
+++ b/app.go
@@ -1,622 +1,632 @@
/*
* 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.7.1"
+ 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)
}
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)
} 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)
} 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) {
// 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.")
}
}
+
+ // 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)
+ }
+ log.Info("Done.")
+
os.Exit(0)
}
diff --git a/less/fonts.less b/less/fonts.less
index fd01dd4..7ee5356 100644
--- a/less/fonts.less
+++ b/less/fonts.less
@@ -1,61 +1,66 @@
/* open-sans-regular - latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
+ font-display: optional;
src: url('/fonts/open-sans-v13-latin-regular.eot'); /* IE9 Compat Modes */
src: local('Open Sans'), local('OpenSans'),
url('/fonts/open-sans-v13-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('/fonts/open-sans-v13-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */
url('/fonts/open-sans-v13-latin-regular.woff') format('woff'), /* Modern Browsers */
url('/fonts/open-sans-v13-latin-regular.ttf') format('truetype'), /* Safari, Android, iOS */
url('/fonts/open-sans-v13-latin-regular.svg#OpenSans') format('svg'); /* Legacy iOS */
}
/* open-sans-700 - latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 700;
+ font-display: optional;
src: url('/fonts/open-sans-v13-latin-700.eot'); /* IE9 Compat Modes */
src: local('Open Sans Bold'), local('OpenSans-Bold'),
url('/fonts/open-sans-v13-latin-700.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('/fonts/open-sans-v13-latin-700.woff2') format('woff2'), /* Super Modern Browsers */
url('/fonts/open-sans-v13-latin-700.woff') format('woff'), /* Modern Browsers */
url('/fonts/open-sans-v13-latin-700.ttf') format('truetype'), /* Safari, Android, iOS */
url('/fonts/open-sans-v13-latin-700.svg#OpenSans') format('svg'); /* Legacy iOS */
}
/* lora-regular - latin */
@font-face {
font-family: 'Lora';
font-style: normal;
font-weight: 400;
+ font-display: optional;
src: url('/fonts/Lora-Regular.eot'); /* IE9 Compat Modes */
src: local('Lora'), local('Lora-Regular'),
url('/fonts/Lora-Regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('/fonts/Lora-Regular.woff2') format('woff2'), /* Super Modern Browsers */
url('/fonts/Lora-Regular.woff') format('woff'), /* Modern Browsers */
url('/fonts/Lora-Regular.ttf') format('truetype'); /* Safari, Android, iOS */
}
/* lora-700 - latin */
@font-face {
font-family: 'Lora';
font-style: normal;
font-weight: 700;
+ font-display: optional;
src: url('/fonts/Lora-Bold.eot'); /* IE9 Compat Modes */
src: local('Lora Bold'), local('Lora-Bold'),
url('/fonts/Lora-Bold.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('/fonts/Lora-Bold.woff2') format('woff2'), /* Super Modern Browsers */
url('/fonts/Lora-Bold.woff') format('woff'), /* Modern Browsers */
url('/fonts/Lora-Bold.ttf') format('truetype'); /* Safari, Android, iOS */
}
@font-face {
- font-family: 'Lora';
- font-style: italic;
- font-weight: 400;
- src: url('/fonts/Lora-Italic.eot'); /* IE9 Compat Modes */
- src: local('Lora Italic'), local('Lora-Italic'),
- url('/fonts/Lora-Italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
- url('/fonts/Lora-Italic.woff2') format('woff2'), /* Super Modern Browsers */
- url('/fonts/Lora-Italic.woff') format('woff'), /* Modern Browsers */
- url('/fonts/Lora-Italic.ttf') format('truetype'); /* Safari, Android, iOS */
+ font-family: 'Lora';
+ font-style: italic;
+ font-weight: 400;
+ font-display: optional;
+ src: url('/fonts/Lora-Italic.eot'); /* IE9 Compat Modes */
+ src: local('Lora Italic'), local('Lora-Italic'),
+ url('/fonts/Lora-Italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
+ url('/fonts/Lora-Italic.woff2') format('woff2'), /* Super Modern Browsers */
+ url('/fonts/Lora-Italic.woff') format('woff'), /* Modern Browsers */
+ url('/fonts/Lora-Italic.ttf') format('truetype'); /* Safari, Android, iOS */
}
diff --git a/migrations/migrations.go b/migrations/migrations.go
index 2aa3643..1d6e0c4 100644
--- a/migrations/migrations.go
+++ b/migrations/migrations.go
@@ -1,118 +1,131 @@
/*
* Copyright © 2019 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 migrations contains database migrations for WriteFreely
package migrations
import (
"database/sql"
"github.com/writeas/web-core/log"
)
// TODO: refactor to use the datastore struct from writefreely pkg
type datastore struct {
*sql.DB
driverName string
}
func NewDatastore(db *sql.DB, dn string) *datastore {
return &datastore{db, dn}
}
// TODO: use these consts from writefreely pkg
const (
driverMySQL = "mysql"
driverSQLite = "sqlite3"
)
type Migration interface {
Description() string
Migrate(db *datastore) error
}
type migration struct {
description string
migrate func(db *datastore) error
}
func New(d string, fn func(db *datastore) error) Migration {
return &migration{d, fn}
}
func (m *migration) Description() string {
return m.description
}
func (m *migration) Migrate(db *datastore) error {
return m.migrate(db)
}
var migrations = []Migration{
New("support user invites", supportUserInvites), // -> V1 (v0.8.0)
}
+// CurrentVer returns the current migration version the application is on
+func CurrentVer() int {
+ return len(migrations)
+}
+
+func SetInitialMigrations(db *datastore) error {
+ _, err := db.Exec("INSERT INTO appmigrations (version, migrated, result) VALUES (?, "+db.now()+", ?)", CurrentVer(), "")
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
func Migrate(db *datastore) error {
var version int
var err error
if db.tableExists("appmigrations") {
err = db.QueryRow("SELECT MAX(version) FROM appmigrations").Scan(&version)
} else {
log.Info("Initializing appmigrations table...")
version = 0
_, err = db.Exec(`CREATE TABLE appmigrations (
version ` + db.typeInt() + ` NOT NULL,
migrated ` + db.typeDateTime() + ` NOT NULL,
result ` + db.typeText() + ` NOT NULL
) ` + db.engine() + `;`)
if err != nil {
return err
}
}
if len(migrations[version:]) > 0 {
for i, m := range migrations[version:] {
curVer := version + i + 1
log.Info("Migrating to V%d: %s", curVer, m.Description())
err = m.Migrate(db)
if err != nil {
return err
}
// Update migrations table
_, err = db.Exec("INSERT INTO appmigrations (version, migrated, result) VALUES (?, "+db.now()+", ?)", curVer, "")
if err != nil {
return err
}
}
} else {
log.Info("Database up-to-date. No migrations to run.")
}
return nil
}
func (db *datastore) tableExists(t string) bool {
var dummy string
var err error
if db.driverName == driverSQLite {
err = db.QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", t).Scan(&dummy)
} else {
err = db.QueryRow("SHOW TABLES LIKE '" + t + "'").Scan(&dummy)
}
switch {
case err == sql.ErrNoRows:
return false
case err != nil:
log.Error("Couldn't SHOW TABLES: %v", err)
return false
}
return true
}
diff --git a/schema.sql b/schema.sql
index 6687f5d..b3fae97 100644
--- a/schema.sql
+++ b/schema.sql
@@ -1,229 +1,241 @@
--
-- Database: `writefreely`
--
-- --------------------------------------------------------
--
-- Table structure for table `accesstokens`
--
CREATE TABLE IF NOT EXISTS `accesstokens` (
`token` binary(16) NOT NULL,
`user_id` int(6) NOT NULL,
`sudo` tinyint(1) NOT NULL DEFAULT '0',
`one_time` tinyint(1) NOT NULL DEFAULT '0',
`created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`expires` datetime DEFAULT NULL,
`user_agent` varchar(255) DEFAULT NULL,
PRIMARY KEY (`token`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
--
-- Table structure for table `appcontent`
--
CREATE TABLE IF NOT EXISTS `appcontent` (
`id` varchar(36) NOT NULL,
`content` mediumtext CHARACTER SET utf8 COLLATE utf8_bin NOT NULL,
`updated` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
+--
+-- Table structure for table `appmigrations`
+--
+
+CREATE TABLE `appmigrations` (
+ `version` int(11) NOT NULL,
+ `migrated` datetime NOT NULL,
+ `result` text NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=latin1;
+
+-- --------------------------------------------------------
+
--
-- Table structure for table `collectionattributes`
--
CREATE TABLE IF NOT EXISTS `collectionattributes` (
`collection_id` int(6) NOT NULL,
`attribute` varchar(128) NOT NULL,
`value` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
PRIMARY KEY (`collection_id`,`attribute`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
--
-- Table structure for table `collectionkeys`
--
CREATE TABLE IF NOT EXISTS `collectionkeys` (
`collection_id` int(6) NOT NULL,
`public_key` blob NOT NULL,
`private_key` blob NOT NULL,
PRIMARY KEY (`collection_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
--
-- Table structure for table `collectionpasswords`
--
CREATE TABLE IF NOT EXISTS `collectionpasswords` (
`collection_id` int(6) NOT NULL,
`password` char(60) NOT NULL,
PRIMARY KEY (`collection_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
--
-- Table structure for table `collectionredirects`
--
CREATE TABLE IF NOT EXISTS `collectionredirects` (
`prev_alias` varchar(100) NOT NULL,
`new_alias` varchar(100) NOT NULL,
PRIMARY KEY (`prev_alias`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
--
-- Table structure for table `collections`
--
CREATE TABLE IF NOT EXISTS `collections` (
`id` int(6) NOT NULL AUTO_INCREMENT,
`alias` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
`title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
`description` varchar(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
`style_sheet` text,
`script` text CHARACTER SET utf8mb4 COLLATE utf8mb4_bin,
`format` varchar(8) DEFAULT NULL,
`privacy` tinyint(1) NOT NULL,
`owner_id` int(6) NOT NULL,
`view_count` int(6) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `alias` (`alias`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
--
-- Table structure for table `posts`
--
CREATE TABLE IF NOT EXISTS `posts` (
`id` char(16) NOT NULL,
`slug` varchar(100) DEFAULT NULL,
`modify_token` char(32) DEFAULT NULL,
`text_appearance` char(4) NOT NULL DEFAULT 'norm',
`language` char(2) DEFAULT NULL,
`rtl` tinyint(1) DEFAULT NULL,
`privacy` tinyint(1) NOT NULL,
`owner_id` int(6) DEFAULT NULL,
`collection_id` int(6) DEFAULT NULL,
`pinned_position` tinyint(1) UNSIGNED DEFAULT NULL,
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`view_count` int(6) NOT NULL,
`title` varchar(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
`content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `id_slug` (`collection_id`,`slug`),
UNIQUE KEY `owner_id` (`owner_id`,`id`),
KEY `privacy_id` (`privacy`,`id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
--
-- Table structure for table `remotefollows`
--
CREATE TABLE IF NOT EXISTS `remotefollows` (
`collection_id` int(11) NOT NULL,
`remote_user_id` int(11) NOT NULL,
`created` datetime NOT NULL,
PRIMARY KEY (`collection_id`,`remote_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
--
-- Table structure for table `remoteuserkeys`
--
CREATE TABLE IF NOT EXISTS `remoteuserkeys` (
`id` varchar(255) NOT NULL,
`remote_user_id` int(11) NOT NULL,
`public_key` blob NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `follower_id` (`remote_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
--
-- Table structure for table `remoteusers`
--
CREATE TABLE IF NOT EXISTS `remoteusers` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`actor_id` varchar(255) NOT NULL,
`inbox` varchar(255) NOT NULL,
`shared_inbox` varchar(255) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `collection_id` (`actor_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
--
-- Table structure for table `userattributes`
--
CREATE TABLE IF NOT EXISTS `userattributes` (
`user_id` int(6) NOT NULL,
`attribute` varchar(64) NOT NULL,
`value` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
PRIMARY KEY (`user_id`,`attribute`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
--
-- Table structure for table `userinvites`
--
CREATE TABLE `userinvites` (
`id` char(6) NOT NULL,
`owner_id` int(11) NOT NULL,
`max_uses` smallint(6) DEFAULT NULL,
`created` datetime NOT NULL,
`expires` datetime DEFAULT NULL,
`inactive` tinyint(1) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
--
-- Table structure for table `users`
--
CREATE TABLE IF NOT EXISTS `users` (
`id` int(6) NOT NULL AUTO_INCREMENT,
`username` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
`password` char(60) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
`email` varbinary(255) DEFAULT NULL,
`created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-- --------------------------------------------------------
--
-- Table structure for table `usersinvited`
--
CREATE TABLE `usersinvited` (
`invite_id` char(6) NOT NULL,
`user_id` int(11) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
diff --git a/sqlite.sql b/sqlite.sql
index 6c7160a..90989ed 100644
--- a/sqlite.sql
+++ b/sqlite.sql
@@ -1,217 +1,229 @@
--
-- Database: writefreely
--
-- --------------------------------------------------------
--
-- Table structure for table accesstokens
--
CREATE TABLE IF NOT EXISTS `accesstokens` (
token TEXT NOT NULL PRIMARY KEY,
user_id INTEGER NOT NULL,
sudo INTEGER NOT NULL DEFAULT '0',
one_time INTEGER NOT NULL DEFAULT '0',
created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires DATETIME DEFAULT NULL,
user_agent TEXT DEFAULT NULL
);
-- --------------------------------------------------------
--
-- Table structure for table appcontent
--
CREATE TABLE IF NOT EXISTS `appcontent` (
id TEXT NOT NULL PRIMARY KEY,
content TEXT NOT NULL,
updated DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- --------------------------------------------------------
+--
+-- Table structure for table appmigrations
+--
+
+CREATE TABLE `appmigrations` (
+ `version` INT NOT NULL,
+ `migrated` DATETIME NOT NULL,
+ `result` TEXT NOT NULL
+);
+
+-- --------------------------------------------------------
+
--
-- Table structure for table collectionattributes
--
CREATE TABLE IF NOT EXISTS `collectionattributes` (
collection_id INTEGER NOT NULL,
attribute TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (collection_id, attribute)
);
-- --------------------------------------------------------
--
-- Table structure for table collectionkeys
--
CREATE TABLE IF NOT EXISTS `collectionkeys` (
collection_id INTEGER PRIMARY KEY,
public_key blob NOT NULL,
private_key blob NOT NULL
);
-- --------------------------------------------------------
--
-- Table structure for table collectionpasswords
--
CREATE TABLE IF NOT EXISTS `collectionpasswords` (
collection_id INTEGER PRIMARY KEY,
password TEXT NOT NULL
);
-- --------------------------------------------------------
--
-- Table structure for table collectionredirects
--
CREATE TABLE IF NOT EXISTS `collectionredirects` (
prev_alias TEXT NOT NULL PRIMARY KEY,
new_alias TEXT NOT NULL
);
-- --------------------------------------------------------
--
-- Table structure for table collections
--
CREATE TABLE IF NOT EXISTS `collections` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
alias TEXT DEFAULT NULL UNIQUE,
title TEXT NOT NULL,
description TEXT NOT NULL,
style_sheet TEXT,
script TEXT,
format TEXT DEFAULT NULL,
privacy INTEGER NOT NULL,
owner_id INTEGER NOT NULL,
view_count INTEGER NOT NULL
);
-- --------------------------------------------------------
--
-- Table structure for table posts
--
CREATE TABLE IF NOT EXISTS `posts` (
id TEXT NOT NULL,
slug TEXT DEFAULT NULL,
modify_token TEXT DEFAULT NULL,
text_appearance TEXT NOT NULL DEFAULT 'norm',
language TEXT DEFAULT NULL,
rtl INTEGER DEFAULT NULL,
privacy INTEGER NOT NULL,
owner_id INTEGER DEFAULT NULL,
collection_id INTEGER DEFAULT NULL,
pinned_position INTEGER UNSIGNED DEFAULT NULL,
created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
view_count INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
CONSTRAINT id_slug UNIQUE (collection_id, slug),
CONSTRAINT owner_id UNIQUE (owner_id, id),
CONSTRAINT privacy_id UNIQUE (privacy, id)
);
-- --------------------------------------------------------
--
-- Table structure for table remotefollows
--
CREATE TABLE IF NOT EXISTS `remotefollows` (
collection_id INTEGER NOT NULL,
remote_user_id INTEGER NOT NULL,
created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (collection_id,remote_user_id)
);
-- --------------------------------------------------------
--
-- Table structure for table remoteuserkeys
--
CREATE TABLE IF NOT EXISTS `remoteuserkeys` (
id TEXT NOT NULL,
remote_user_id INTEGER NOT NULL,
public_key blob NOT NULL,
CONSTRAINT follower_id UNIQUE (remote_user_id)
);
-- --------------------------------------------------------
--
-- Table structure for table remoteusers
--
CREATE TABLE IF NOT EXISTS `remoteusers` (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
actor_id TEXT NOT NULL,
inbox TEXT NOT NULL,
shared_inbox TEXT NOT NULL,
CONSTRAINT collection_id UNIQUE (actor_id)
);
-- --------------------------------------------------------
--
-- Table structure for table userattributes
--
CREATE TABLE IF NOT EXISTS `userattributes` (
user_id INTEGER NOT NULL,
attribute TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (user_id, attribute)
);
-- --------------------------------------------------------
--
-- Table structure for table `userinvites`
--
CREATE TABLE `userinvites` (
`id` TEXT NOT NULL,
`owner_id` INTEGER NOT NULL,
`max_uses` INTEGER DEFAULT NULL,
`created` DATETIME NOT NULL,
`expires` DATETIME DEFAULT NULL,
`inactive` INTEGER NOT NULL
);
-- --------------------------------------------------------
--
-- Table structure for table users
--
CREATE TABLE IF NOT EXISTS `users` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
email TEXT DEFAULT NULL,
created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- --------------------------------------------------------
--
-- Table structure for table `usersinvited`
--
CREATE TABLE `usersinvited` (
`invite_id` TEXT NOT NULL,
`user_id` INTEGER NOT NULL
);