diff --git a/.gitignore b/.gitignore index 4e501c0..228e67d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ *.swp *.swo +build config.ini diff --git a/AUTHORS.md b/AUTHORS.md new file mode 100644 index 0000000..bf363a6 --- /dev/null +++ b/AUTHORS.md @@ -0,0 +1,8 @@ +# 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) + diff --git a/Makefile b/Makefile index ee6414f..126bbb1 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,15 @@ all : build build: deps cd cmd/writefreely; $(GOBUILD) -v +build-linux: deps + cd cmd/writefreely; GOOS=linux GOARCH=amd64 $(GOBUILD) -v + +build-windows: deps + cd cmd/writefreely; GOOS=windows GOARCH=amd64 $(GOBUILD) -v + +build-darwin: deps + cd cmd/writefreely; GOOS=darwin GOARCH=amd64 $(GOBUILD) -v + test: $(GOTEST) -v ./... @@ -27,10 +36,30 @@ install : build cmd/writefreely/$(BINARY_NAME) --gen-keys cd less/; $(MAKE) install $(MFLAGS) +release : clean ui + mkdir build + cp -r templates build + cp -r pages build + cp -r static build + mkdir build/keys + cp schema.sql build + $(MAKE) build-linux + cp cmd/writefreely/$(BINARY_NAME) build + cd build; tar -cvzf ../$(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz * + rm build/$(BINARY_NAME) + $(MAKE) build-darwin + cp cmd/writefreely/$(BINARY_NAME) build + cd build; tar -cvzf ../$(BINARY_NAME)_$(GITREV)_darwin_amd64.tar.gz * + rm build/$(BINARY_NAME) + $(MAKE) build-windows + cp cmd/writefreely/$(BINARY_NAME).exe build + cd build; zip -r ../$(BINARY_NAME)_$(GITREV)_windows_amd64.zip ./* + ui : force_look cd less/; $(MAKE) $(MFLAGS) clean : + -rm -rf build cd less/; $(MAKE) clean $(MFLAGS) force_look : diff --git a/README.md b/README.md index b5cf0db..6467555 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ 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 lightweight. +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)** @@ -123,6 +123,12 @@ will shut down your environment without destroying your data. 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 ada7392..d72ee49 100644 --- a/app.go +++ b/app.go @@ -20,6 +20,7 @@ import ( "github.com/gorilla/schema" "github.com/gorilla/sessions" "github.com/manifoldco/promptui" + "github.com/writeas/go-strip-markdown" "github.com/writeas/web-core/converter" "github.com/writeas/web-core/log" "github.com/writeas/writefreely/config" @@ -97,8 +98,11 @@ func handleViewHome(app *app, w http.ResponseWriter, r *http.Request) error { func handleTemplatedPage(app *app, w http.ResponseWriter, r *http.Request, t *template.Template) error { p := struct { page.StaticPage - Content template.HTML - Updated string + Content template.HTML + PlainContent string + Updated string + + AboutStats *InstanceStats }{ StaticPage: pageForReq(app, r), } @@ -109,6 +113,11 @@ func handleTemplatedPage(app *app, w http.ResponseWriter, r *http.Request, t *te 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) } @@ -117,6 +126,7 @@ func handleTemplatedPage(app *app, w http.ResponseWriter, r *http.Request, t *te 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") } @@ -357,7 +367,7 @@ func Serve() { app.cfg.Database.Host = "localhost" } if app.cfg.Database.Database == "" { - app.cfg.Database.Database = "writeas" + app.cfg.Database.Database = "writefreely" } connectToDatabase(app) @@ -391,11 +401,26 @@ func Serve() { os.Exit(0) }() - // Start web application server http.Handle("/", r) - log.Info("Serving on http://localhost:%d\n", app.cfg.Server.Port) - log.Info("---") - err = http.ListenAndServe(fmt.Sprintf(":%d", app.cfg.Server.Port), nil) + + // Start web application server + if app.cfg.IsSecureStandalone() { + log.Info("Serving redirects on http://localhost:80") + go func() { + err = http.ListenAndServe(":80", 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://localhost:443") + log.Info("---") + err = http.ListenAndServeTLS(":443", app.cfg.Server.TLSCertPath, app.cfg.Server.TLSKeyPath, nil) + } else { + log.Info("Serving on http://localhost:%d\n", app.cfg.Server.Port) + log.Info("---") + err = http.ListenAndServe(fmt.Sprintf(":%d", app.cfg.Server.Port), nil) + } if err != nil { log.Error("Unable to start: %v", err) os.Exit(1) @@ -403,8 +428,13 @@ func Serve() { } func connectToDatabase(app *app) { - log.Info("Connecting to database...") - db, err := sql.Open("mysql", 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()))) + if app.cfg.Database.Type != "mysql" { + log.Error("Invalid database type '%s'. Only 'mysql' is supported right now.", app.cfg.Database.Type) + os.Exit(1) + } + + log.Info("Connecting to %s database...", app.cfg.Database.Type) + 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()))) if err != nil { log.Error("%s", err) os.Exit(1) diff --git a/cmd/writefreely/.gitignore b/cmd/writefreely/.gitignore index 0c3aa8d..6c3985c 100644 --- a/cmd/writefreely/.gitignore +++ b/cmd/writefreely/.gitignore @@ -1 +1,2 @@ writefreely +writefreely.exe diff --git a/config/config.go b/config/config.go index c3c0628..56d8848 100644 --- a/config/config.go +++ b/config/config.go @@ -13,6 +13,9 @@ type ( HiddenHost string `ini:"hidden_host"` Port int `ini:"port"` + TLSCertPath string `ini:"tls_cert_path"` + TLSKeyPath string `ini:"tls_key_path"` + Dev bool `ini:"-"` } @@ -76,6 +79,10 @@ func New() *Config { } } +func (cfg *Config) IsSecureStandalone() bool { + return cfg.Server.Port == 443 && cfg.Server.TLSCertPath != "" && cfg.Server.TLSKeyPath != "" +} + func Load() (*Config, error) { cfg, err := ini.Load(FileName) if err != nil { diff --git a/config/setup.go b/config/setup.go index 54fa961..6a2f780 100644 --- a/config/setup.go +++ b/config/setup.go @@ -47,17 +47,80 @@ func Configure() (*SetupData, error) { Selected: fmt.Sprintf(`{{.Label}} {{ . | faint }}`), } - prompt := promptui.Prompt{ - Templates: tmpls, - Label: "Local port", - Validate: validatePort, - Default: fmt.Sprintf("%d", data.Config.Server.Port), + // Environment selection + selPrompt := promptui.Select{ + Templates: selTmpls, + Label: "Environment", + Items: []string{"Development", "Production, standalone", "Production, behind reverse proxy"}, } - port, err := prompt.Run() + _, envType, err := selPrompt.Run() if err != nil { return data, err } - data.Config.Server.Port, _ = strconv.Atoi(port) // Ignore error, as we've already validated number + isDevEnv := envType == "Development" + isStandalone := envType == "Production, standalone" + + data.Config.Server.Dev = isDevEnv + + var prompt promptui.Prompt + if isDevEnv || !isStandalone { + // Running in dev environment or behind reverse proxy; ask for port + prompt = promptui.Prompt{ + Templates: tmpls, + Label: "Local port", + Validate: validatePort, + Default: fmt.Sprintf("%d", data.Config.Server.Port), + } + port, err := prompt.Run() + if err != nil { + return data, err + } + data.Config.Server.Port, _ = strconv.Atoi(port) // Ignore error, as we've already validated number + } + + if isStandalone { + selPrompt = promptui.Select{ + Templates: selTmpls, + Label: "Web server mode", + Items: []string{"Insecure (port 80)", "Secure (port 443)"}, + } + sel, _, err := selPrompt.Run() + if err != nil { + return data, err + } + if sel == 0 { + data.Config.Server.Port = 80 + data.Config.Server.TLSCertPath = "" + data.Config.Server.TLSKeyPath = "" + } else if sel == 1 { + data.Config.Server.Port = 443 + + prompt = promptui.Prompt{ + Templates: tmpls, + Label: "Certificate path", + Validate: validateNonEmpty, + Default: data.Config.Server.TLSCertPath, + } + data.Config.Server.TLSCertPath, err = prompt.Run() + if err != nil { + return data, err + } + + prompt = promptui.Prompt{ + Templates: tmpls, + Label: "Key path", + Validate: validateNonEmpty, + Default: data.Config.Server.TLSKeyPath, + } + data.Config.Server.TLSKeyPath, err = prompt.Run() + if err != nil { + return data, err + } + } + } else { + data.Config.Server.TLSCertPath = "" + data.Config.Server.TLSKeyPath = "" + } fmt.Println() title(" Database setup ") @@ -124,7 +187,7 @@ func Configure() (*SetupData, error) { title(" App setup ") fmt.Println() - selPrompt := promptui.Select{ + selPrompt = promptui.Select{ Templates: selTmpls, Label: "Site type", Items: []string{"Single user blog", "Multi-user instance"}, diff --git a/database.go b/database.go index 0c79e10..468b04d 100644 --- a/database.go +++ b/database.go @@ -50,6 +50,8 @@ type writestore interface { GetCollections(u *User) (*[]Collection, error) GetPublishableCollections(u *User) (*[]Collection, error) GetMeStats(u *User) userMeStats + GetTotalCollections() (int64, error) + GetTotalPosts() (int64, error) GetTopPosts(u *User, alias string) (*[]PublicPost, error) GetAnonymousPosts(u *User) (*[]PublicPost, error) GetUserPosts(u *User) (*[]PublicPost, error) @@ -1541,6 +1543,22 @@ func (db *datastore) GetMeStats(u *User) userMeStats { return s } +func (db *datastore) GetTotalCollections() (collCount int64, err error) { + err = db.QueryRow(`SELECT COUNT(*) FROM collections`).Scan(&collCount) + if err != nil { + log.Error("Unable to fetch collections count: %v", err) + } + return +} + +func (db *datastore) GetTotalPosts() (postCount int64, err error) { + err = db.QueryRow(`SELECT COUNT(*) FROM posts`).Scan(&postCount) + if err != nil { + log.Error("Unable to fetch posts count: %v", err) + } + return +} + func (db *datastore) GetTopPosts(u *User, alias string) (*[]PublicPost, error) { params := []interface{}{u.ID} where := "" diff --git a/instance.go b/instance.go new file mode 100644 index 0000000..482e65d --- /dev/null +++ b/instance.go @@ -0,0 +1,6 @@ +package writefreely + +type InstanceStats struct { + NumPosts int64 + NumBlogs int64 +} diff --git a/nodeinfo.go b/nodeinfo.go index fd4b445..eab15cc 100644 --- a/nodeinfo.go +++ b/nodeinfo.go @@ -57,12 +57,14 @@ func (r nodeInfoResolver) IsOpenRegistration() (bool, error) { } func (r nodeInfoResolver) Usage() (nodeinfo.Usage, error) { - var collCount, postCount, activeHalfYear, activeMonth int - err := r.db.QueryRow(`SELECT COUNT(*) FROM collections`).Scan(&collCount) + var collCount, postCount int64 + var activeHalfYear, activeMonth int + var err error + collCount, err = r.db.GetTotalCollections() if err != nil { collCount = 0 } - err = r.db.QueryRow(`SELECT COUNT(*) FROM posts`).Scan(&postCount) + postCount, err = r.db.GetTotalPosts() if err != nil { log.Error("Unable to fetch post counts: %v", err) } @@ -88,10 +90,10 @@ WHERE collection_id IS NOT NULL return nodeinfo.Usage{ Users: nodeinfo.UsageUsers{ - Total: collCount, + Total: int(collCount), ActiveHalfYear: activeHalfYear, ActiveMonth: activeMonth, }, - LocalPosts: postCount, + LocalPosts: int(postCount), }, nil } diff --git a/pages/about.tmpl b/pages/about.tmpl index a47faf6..64c2500 100644 --- a/pages/about.tmpl +++ b/pages/about.tmpl @@ -1,4 +1,5 @@ {{define "head"}}About {{.SiteName}} + {{end}} {{define "content"}}
@@ -6,6 +7,11 @@ {{.Content}} + {{if .Federation}} +
+

{{.SiteName}} is home to {{largeNumFmt .AboutStats.NumPosts}} {{pluralize "article" "articles" .AboutStats.NumPosts}} across {{largeNumFmt .AboutStats.NumBlogs}} {{pluralize "blog" "blogs" .AboutStats.NumBlogs}}.

+ {{end}} +

About WriteFreely

WriteFreely is a self-hosted, decentralized blogging platform for publishing beautiful, simple blogs.

It lets you publish a single blog, or host a community of writers who can create multiple blogs under one account. You can also enable federation, which allows people in the fediverse to follow your blog, bookmark your posts, and share them with others.

diff --git a/pages/privacy.tmpl b/pages/privacy.tmpl index a32d028..9d0d177 100644 --- a/pages/privacy.tmpl +++ b/pages/privacy.tmpl @@ -1,4 +1,5 @@ {{define "head"}}{{.SiteName}} Privacy Policy + {{end}} {{define "content"}}

Privacy Policy

diff --git a/templates/user/admin.tmpl b/templates/user/admin.tmpl index 228dfc3..bdfbd71 100644 --- a/templates/user/admin.tmpl +++ b/templates/user/admin.tmpl @@ -56,7 +56,7 @@ function savePage(el) {

Site

About page

-

Describe what your instance is about. Accepts Markdown.

+

Describe what your instance is about. Accepts Markdown.