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

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. -**[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 );