diff --git a/account.go b/account.go
index d8ea0df..1cf259b 100644
--- a/account.go
+++ b/account.go
@@ -1,1064 +1,1064 @@
/*
* Copyright © 2018-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 writefreely
import (
"encoding/json"
"fmt"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/guregu/null/zero"
"github.com/writeas/impart"
"github.com/writeas/web-core/auth"
"github.com/writeas/web-core/data"
"github.com/writeas/web-core/log"
"github.com/writeas/writefreely/author"
"github.com/writeas/writefreely/page"
"html/template"
"net/http"
"regexp"
"strings"
"sync"
"time"
)
type (
userSettings struct {
Username string `schema:"username" json:"username"`
Email string `schema:"email" json:"email"`
NewPass string `schema:"new-pass" json:"new_pass"`
OldPass string `schema:"current-pass" json:"current_pass"`
IsLogOut bool `schema:"logout" json:"logout"`
}
UserPage struct {
page.StaticPage
PageTitle string
Separator template.HTML
IsAdmin bool
CanInvite bool
}
)
func NewUserPage(app *App, r *http.Request, u *User, title string, flashes []string) *UserPage {
up := &UserPage{
StaticPage: pageForReq(app, r),
PageTitle: title,
}
up.Username = u.Username
up.Flashes = flashes
up.Path = r.URL.Path
up.IsAdmin = u.IsAdmin()
up.CanInvite = app.cfg.App.UserInvites != "" &&
(up.IsAdmin || app.cfg.App.UserInvites != "admin")
return up
}
func (up *UserPage) SetMessaging(u *User) {
//up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID)
}
const (
loginAttemptExpiration = 3 * time.Second
)
var actuallyUsernameReg = regexp.MustCompile("username is actually ([a-z0-9\\-]+)\\. Please try that, instead")
func apiSignup(app *App, w http.ResponseWriter, r *http.Request) error {
_, err := signup(app, w, r)
return err
}
func signup(app *App, w http.ResponseWriter, r *http.Request) (*AuthUser, error) {
reqJSON := IsJSON(r.Header.Get("Content-Type"))
// Get params
var ur userRegistration
if reqJSON {
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&ur)
if err != nil {
log.Error("Couldn't parse signup JSON request: %v\n", err)
return nil, ErrBadJSON
}
} else {
// Check if user is already logged in
u := getUserSession(app, r)
if u != nil {
return &AuthUser{User: u}, nil
}
err := r.ParseForm()
if err != nil {
log.Error("Couldn't parse signup form request: %v\n", err)
return nil, ErrBadFormData
}
err = app.formDecoder.Decode(&ur, r.PostForm)
if err != nil {
log.Error("Couldn't decode signup form request: %v\n", err)
return nil, ErrBadFormData
}
}
return signupWithRegistration(app, ur, w, r)
}
func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWriter, r *http.Request) (*AuthUser, error) {
reqJSON := IsJSON(r.Header.Get("Content-Type"))
// Validate required params (alias)
if signup.Alias == "" {
return nil, impart.HTTPError{http.StatusBadRequest, "A username is required."}
}
if signup.Pass == "" {
return nil, impart.HTTPError{http.StatusBadRequest, "A password is required."}
}
var desiredUsername string
if signup.Normalize {
// With this option we simply conform the username to what we expect
// without complaining. Since they might've done something funny, like
// enter: write.as/Way Out There, we'll use their raw input for the new
// collection name and sanitize for the slug / username.
desiredUsername = signup.Alias
signup.Alias = getSlug(signup.Alias, "")
}
if !author.IsValidUsername(app.cfg, signup.Alias) {
// Ensure the username is syntactically correct.
return nil, impart.HTTPError{http.StatusPreconditionFailed, "Username is reserved or isn't valid. It must be at least 3 characters long, and can only include letters, numbers, and hyphens."}
}
// Handle empty optional params
// TODO: remove this var
createdWithPass := true
hashedPass, err := auth.HashPass([]byte(signup.Pass))
if err != nil {
return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
}
// Create struct to insert
u := &User{
Username: signup.Alias,
HashedPass: hashedPass,
HasPass: createdWithPass,
Email: zero.NewString("", signup.Email != ""),
Created: time.Now().Truncate(time.Second).UTC(),
}
if signup.Email != "" {
encEmail, err := data.Encrypt(app.keys.EmailKey, signup.Email)
if err != nil {
log.Error("Unable to encrypt email: %s\n", err)
} else {
u.Email.String = string(encEmail)
}
}
// Create actual user
- if err := app.db.CreateUser(u, desiredUsername); err != nil {
+ if err := app.db.CreateUser(app.cfg, u, desiredUsername); err != nil {
return nil, err
}
// Log invite if needed
if signup.InviteCode != "" {
cu, err := app.db.GetUserForAuth(signup.Alias)
if err != nil {
return nil, err
}
err = app.db.CreateInvitedUser(signup.InviteCode, cu.ID)
if err != nil {
return nil, err
}
}
// Add back unencrypted data for response
if signup.Email != "" {
u.Email.String = signup.Email
}
resUser := &AuthUser{
User: u,
}
if !createdWithPass {
resUser.Password = signup.Pass
}
title := signup.Alias
if signup.Normalize {
title = desiredUsername
}
resUser.Collections = &[]Collection{
{
Alias: signup.Alias,
Title: title,
},
}
var token string
if reqJSON && !signup.Web {
token, err = app.db.GetAccessToken(u.ID)
if err != nil {
return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create access token. Try re-authenticating."}
}
resUser.AccessToken = token
} else {
session, err := app.sessionStore.Get(r, cookieName)
if err != nil {
// The cookie should still save, even if there's an error.
// Source: https://github.com/gorilla/sessions/issues/16#issuecomment-143642144
log.Error("Session: %v; ignoring", err)
}
session.Values[cookieUserVal] = resUser.User.Cookie()
err = session.Save(r, w)
if err != nil {
log.Error("Couldn't save session: %v", err)
return nil, err
}
}
if reqJSON {
return resUser, impart.WriteSuccess(w, resUser, http.StatusCreated)
}
return resUser, nil
}
func viewLogout(app *App, w http.ResponseWriter, r *http.Request) error {
session, err := app.sessionStore.Get(r, cookieName)
if err != nil {
return ErrInternalCookieSession
}
// Ensure user has an email or password set before they go, so they don't
// lose access to their account.
val := session.Values[cookieUserVal]
var u = &User{}
var ok bool
if u, ok = val.(*User); !ok {
log.Error("Error casting user object on logout. Vals: %+v Resetting cookie.", session.Values)
err = session.Save(r, w)
if err != nil {
log.Error("Couldn't save session on logout: %v", err)
return impart.HTTPError{http.StatusInternalServerError, "Unable to save cookie session."}
}
return impart.HTTPError{http.StatusFound, "/"}
}
u, err = app.db.GetUserByID(u.ID)
if err != nil && err != ErrUserNotFound {
return impart.HTTPError{http.StatusInternalServerError, "Unable to fetch user information."}
}
session.Options.MaxAge = -1
err = session.Save(r, w)
if err != nil {
log.Error("Couldn't save session on logout: %v", err)
return impart.HTTPError{http.StatusInternalServerError, "Unable to save cookie session."}
}
return impart.HTTPError{http.StatusFound, "/"}
}
func handleAPILogout(app *App, w http.ResponseWriter, r *http.Request) error {
accessToken := r.Header.Get("Authorization")
if accessToken == "" {
return ErrNoAccessToken
}
t := auth.GetToken(accessToken)
if len(t) == 0 {
return ErrNoAccessToken
}
err := app.db.DeleteToken(t)
if err != nil {
return err
}
return impart.HTTPError{Status: http.StatusNoContent}
}
func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
var earlyError string
oneTimeToken := r.FormValue("with")
if oneTimeToken != "" {
log.Info("Calling login with one-time token.")
err := login(app, w, r)
if err != nil {
log.Info("Received error: %v", err)
earlyError = fmt.Sprintf("%s", err)
}
}
session, err := app.sessionStore.Get(r, cookieName)
if err != nil {
// Ignore this
log.Error("Unable to get session; ignoring: %v", err)
}
p := &struct {
page.StaticPage
To string
Message template.HTML
Flashes []template.HTML
Username string
}{
pageForReq(app, r),
r.FormValue("to"),
template.HTML(""),
[]template.HTML{},
getTempInfo(app, "login-user", r, w),
}
if earlyError != "" {
p.Flashes = append(p.Flashes, template.HTML(earlyError))
}
// Display any error messages
flashes, _ := getSessionFlashes(app, w, r, session)
for _, flash := range flashes {
p.Flashes = append(p.Flashes, template.HTML(flash))
}
err = pages["login.tmpl"].ExecuteTemplate(w, "base", p)
if err != nil {
log.Error("Unable to render login: %v", err)
return err
}
return nil
}
func webLogin(app *App, w http.ResponseWriter, r *http.Request) error {
err := login(app, w, r)
if err != nil {
username := r.FormValue("alias")
// Login request was unsuccessful; save the error in the session and redirect them
if err, ok := err.(impart.HTTPError); ok {
session, _ := app.sessionStore.Get(r, cookieName)
if session != nil {
session.AddFlash(err.Message)
session.Save(r, w)
}
if m := actuallyUsernameReg.FindStringSubmatch(err.Message); len(m) > 0 {
// Retain fixed username recommendation for the login form
username = m[1]
}
}
// Pass along certain information
saveTempInfo(app, "login-user", username, r, w)
// Retain post-login URL if one was given
redirectTo := "/login"
postLoginRedirect := r.FormValue("to")
if postLoginRedirect != "" {
redirectTo += "?to=" + postLoginRedirect
}
log.Error("Unable to login: %v", err)
return impart.HTTPError{http.StatusTemporaryRedirect, redirectTo}
}
return nil
}
var loginAttemptUsers = sync.Map{}
func login(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type"))
oneTimeToken := r.FormValue("with")
verbose := r.FormValue("all") == "true" || r.FormValue("verbose") == "1" || r.FormValue("verbose") == "true" || (reqJSON && oneTimeToken != "")
redirectTo := r.FormValue("to")
if redirectTo == "" {
if app.cfg.App.SingleUser {
redirectTo = "/me/new"
} else {
redirectTo = "/"
}
}
var u *User
var err error
var signin userCredentials
// Log in with one-time token if one is given
if oneTimeToken != "" {
log.Info("Login: Logging user in via token.")
userID := app.db.GetUserID(oneTimeToken)
if userID == -1 {
log.Error("Login: Got user -1 from token")
err := ErrBadAccessToken
err.Message = "Expired or invalid login code."
return err
}
log.Info("Login: Found user %d.", userID)
u, err = app.db.GetUserByID(userID)
if err != nil {
log.Error("Unable to fetch user on one-time token login: %v", err)
return impart.HTTPError{http.StatusInternalServerError, "There was an error retrieving the user you want."}
}
log.Info("Login: Got user via token")
} else {
// Get params
if reqJSON {
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&signin)
if err != nil {
log.Error("Couldn't parse signin JSON request: %v\n", err)
return ErrBadJSON
}
} else {
err := r.ParseForm()
if err != nil {
log.Error("Couldn't parse signin form request: %v\n", err)
return ErrBadFormData
}
err = app.formDecoder.Decode(&signin, r.PostForm)
if err != nil {
log.Error("Couldn't decode signin form request: %v\n", err)
return ErrBadFormData
}
}
log.Info("Login: Attempting login for '%s'", signin.Alias)
// Validate required params (all)
if signin.Alias == "" {
msg := "Parameter `alias` required."
if signin.Web {
msg = "A username is required."
}
return impart.HTTPError{http.StatusBadRequest, msg}
}
if !signin.EmailLogin && signin.Pass == "" {
msg := "Parameter `pass` required."
if signin.Web {
msg = "A password is required."
}
return impart.HTTPError{http.StatusBadRequest, msg}
}
// Prevent excessive login attempts on the same account
// Skip this check in dev environment
if !app.cfg.Server.Dev {
now := time.Now()
attemptExp, att := loginAttemptUsers.LoadOrStore(signin.Alias, now.Add(loginAttemptExpiration))
if att {
if attemptExpTime, ok := attemptExp.(time.Time); ok {
if attemptExpTime.After(now) {
// This user attempted previously, and the period hasn't expired yet
return impart.HTTPError{http.StatusTooManyRequests, "You're doing that too much."}
} else {
// This user attempted previously, but the time expired; free up space
loginAttemptUsers.Delete(signin.Alias)
}
} else {
log.Error("Unable to cast expiration to time")
}
}
}
// Retrieve password
u, err = app.db.GetUserForAuth(signin.Alias)
if err != nil {
log.Info("Unable to getUserForAuth on %s: %v", signin.Alias, err)
if strings.IndexAny(signin.Alias, "@") > 0 {
log.Info("Suggesting: %s", ErrUserNotFoundEmail.Message)
return ErrUserNotFoundEmail
}
return err
}
// Authenticate
if u.Email.String == "" {
// User has no email set, so check if they haven't added a password, either,
// so we can return a more helpful error message.
if hasPass, _ := app.db.IsUserPassSet(u.ID); !hasPass {
log.Info("Tried logging in to %s, but no password or email.", signin.Alias)
return impart.HTTPError{http.StatusPreconditionFailed, "This user never added a password or email address. Please contact us for help."}
}
}
if !auth.Authenticated(u.HashedPass, []byte(signin.Pass)) {
return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."}
}
}
if reqJSON && !signin.Web {
var token string
if r.Header.Get("User-Agent") == "" {
// Get last created token when User-Agent is empty
token = app.db.FetchLastAccessToken(u.ID)
if token == "" {
token, err = app.db.GetAccessToken(u.ID)
}
} else {
token, err = app.db.GetAccessToken(u.ID)
}
if err != nil {
log.Error("Login: Unable to create access token: %v", err)
return impart.HTTPError{http.StatusInternalServerError, "Could not create access token. Try re-authenticating."}
}
resUser := getVerboseAuthUser(app, token, u, verbose)
return impart.WriteSuccess(w, resUser, http.StatusOK)
}
session, err := app.sessionStore.Get(r, cookieName)
if err != nil {
// The cookie should still save, even if there's an error.
log.Error("Login: Session: %v; ignoring", err)
}
// Remove unwanted data
session.Values[cookieUserVal] = u.Cookie()
err = session.Save(r, w)
if err != nil {
log.Error("Login: Couldn't save session: %v", err)
// TODO: return error
}
// Send success
if reqJSON {
return impart.WriteSuccess(w, &AuthUser{User: u}, http.StatusOK)
}
log.Info("Login: Redirecting to %s", redirectTo)
w.Header().Set("Location", redirectTo)
w.WriteHeader(http.StatusFound)
return nil
}
func getVerboseAuthUser(app *App, token string, u *User, verbose bool) *AuthUser {
resUser := &AuthUser{
AccessToken: token,
User: u,
}
// Fetch verbose user data if requested
if verbose {
posts, err := app.db.GetUserPosts(u)
if err != nil {
log.Error("Login: Unable to get user posts: %v", err)
}
colls, err := app.db.GetCollections(u)
if err != nil {
log.Error("Login: Unable to get user collections: %v", err)
}
passIsSet, err := app.db.IsUserPassSet(u.ID)
if err != nil {
// TODO: correct error meesage
log.Error("Login: Unable to get user collections: %v", err)
}
resUser.Posts = posts
resUser.Collections = colls
resUser.User.HasPass = passIsSet
}
return resUser
}
func viewExportOptions(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
// Fetch extra user data
p := NewUserPage(app, r, u, "Export", nil)
showUserPage(w, "export", p)
return nil
}
func viewExportPosts(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
var filename string
var u = &User{}
reqJSON := IsJSON(r.Header.Get("Content-Type"))
if reqJSON {
// Use given Authorization header
accessToken := r.Header.Get("Authorization")
if accessToken == "" {
return nil, filename, ErrNoAccessToken
}
userID := app.db.GetUserID(accessToken)
if userID == -1 {
return nil, filename, ErrBadAccessToken
}
var err error
u, err = app.db.GetUserByID(userID)
if err != nil {
return nil, filename, impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve requested user."}
}
} else {
// Use user cookie
session, err := app.sessionStore.Get(r, cookieName)
if err != nil {
// The cookie should still save, even if there's an error.
log.Error("Session: %v; ignoring", err)
}
val := session.Values[cookieUserVal]
var ok bool
if u, ok = val.(*User); !ok {
return nil, filename, ErrNotLoggedIn
}
}
filename = u.Username + "-posts-" + time.Now().Truncate(time.Second).UTC().Format("200601021504")
// Fetch data we're exporting
var err error
var data []byte
posts, err := app.db.GetUserPosts(u)
if err != nil {
return data, filename, err
}
// Export as CSV
if strings.HasSuffix(r.URL.Path, ".csv") {
data = exportPostsCSV(u, posts)
return data, filename, err
}
if strings.HasSuffix(r.URL.Path, ".zip") {
data = exportPostsZip(u, posts)
return data, filename, err
}
if r.FormValue("pretty") == "1" {
data, err = json.MarshalIndent(posts, "", "\t")
} else {
data, err = json.Marshal(posts)
}
return data, filename, err
}
func viewExportFull(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
var err error
filename := ""
u := getUserSession(app, r)
if u == nil {
return nil, filename, ErrNotLoggedIn
}
filename = u.Username + "-" + time.Now().Truncate(time.Second).UTC().Format("200601021504")
exportUser := compileFullExport(app, u)
var data []byte
if r.FormValue("pretty") == "1" {
data, err = json.MarshalIndent(exportUser, "", "\t")
} else {
data, err = json.Marshal(exportUser)
}
return data, filename, err
}
func viewMeAPI(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type"))
uObj := struct {
ID int64 `json:"id,omitempty"`
Username string `json:"username,omitempty"`
}{}
var err error
if reqJSON {
_, uObj.Username, err = app.db.GetUserDataFromToken(r.Header.Get("Authorization"))
if err != nil {
return err
}
} else {
u := getUserSession(app, r)
if u == nil {
return impart.WriteSuccess(w, uObj, http.StatusOK)
}
uObj.Username = u.Username
}
return impart.WriteSuccess(w, uObj, http.StatusOK)
}
func viewMyPostsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type"))
if !reqJSON {
return ErrBadRequestedType
}
var err error
p := GetPostsCache(u.ID)
if p == nil {
userPostsCache.Lock()
if userPostsCache.users[u.ID].ready == nil {
userPostsCache.users[u.ID] = postsCacheItem{ready: make(chan struct{})}
userPostsCache.Unlock()
p, err = app.db.GetUserPosts(u)
if err != nil {
return err
}
CachePosts(u.ID, p)
} else {
userPostsCache.Unlock()
<-userPostsCache.users[u.ID].ready
p = GetPostsCache(u.ID)
}
}
return impart.WriteSuccess(w, p, http.StatusOK)
}
func viewMyCollectionsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type"))
if !reqJSON {
return ErrBadRequestedType
}
p, err := app.db.GetCollections(u)
if err != nil {
return err
}
return impart.WriteSuccess(w, p, http.StatusOK)
}
func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
p, err := app.db.GetAnonymousPosts(u)
if err != nil {
log.Error("unable to fetch anon posts: %v", err)
}
// nil-out AnonymousPosts slice for easy detection in the template
if p != nil && len(*p) == 0 {
p = nil
}
f, err := getSessionFlashes(app, w, r, nil)
if err != nil {
log.Error("unable to fetch flashes: %v", err)
}
c, err := app.db.GetPublishableCollections(u)
if err != nil {
log.Error("unable to fetch collections: %v", err)
}
d := struct {
*UserPage
AnonymousPosts *[]PublicPost
Collections *[]Collection
}{
UserPage: NewUserPage(app, r, u, u.Username+"'s Posts", f),
AnonymousPosts: p,
Collections: c,
}
d.UserPage.SetMessaging(u)
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Expires", "Thu, 04 Oct 1990 20:00:00 GMT")
showUserPage(w, "articles", d)
return nil
}
func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
c, err := app.db.GetCollections(u)
if err != nil {
log.Error("unable to fetch collections: %v", err)
return fmt.Errorf("No collections")
}
f, _ := getSessionFlashes(app, w, r, nil)
uc, _ := app.db.GetUserCollectionCount(u.ID)
// TODO: handle any errors
d := struct {
*UserPage
Collections *[]Collection
UsedCollections, TotalCollections int
NewBlogsDisabled bool
}{
UserPage: NewUserPage(app, r, u, u.Username+"'s Blogs", f),
Collections: c,
UsedCollections: int(uc),
NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc),
}
d.UserPage.SetMessaging(u)
showUserPage(w, "collections", d)
return nil
}
func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
c, err := app.db.GetCollection(vars["collection"])
if err != nil {
return err
}
if c.OwnerID != u.ID {
return ErrCollectionNotFound
}
flashes, _ := getSessionFlashes(app, w, r, nil)
obj := struct {
*UserPage
*Collection
}{
UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
Collection: c,
}
showUserPage(w, "collection", obj)
return nil
}
func updateSettings(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type"))
var s userSettings
var u *User
var sess *sessions.Session
var err error
if reqJSON {
accessToken := r.Header.Get("Authorization")
if accessToken == "" {
return ErrNoAccessToken
}
u, err = app.db.GetAPIUser(accessToken)
if err != nil {
return ErrBadAccessToken
}
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&s)
if err != nil {
log.Error("Couldn't parse settings JSON request: %v\n", err)
return ErrBadJSON
}
// Prevent all username updates
// TODO: support changing username via JSON API request
s.Username = ""
} else {
u, sess = getUserAndSession(app, r)
if u == nil {
return ErrNotLoggedIn
}
err := r.ParseForm()
if err != nil {
log.Error("Couldn't parse settings form request: %v\n", err)
return ErrBadFormData
}
err = app.formDecoder.Decode(&s, r.PostForm)
if err != nil {
log.Error("Couldn't decode settings form request: %v\n", err)
return ErrBadFormData
}
}
// Do update
postUpdateReturn := r.FormValue("return")
redirectTo := "/me/settings"
if s.IsLogOut {
redirectTo += "?logout=1"
} else if postUpdateReturn != "" {
redirectTo = postUpdateReturn
}
// Only do updates on values we need
if s.Username != "" && s.Username == u.Username {
// Username hasn't actually changed; blank it out
s.Username = ""
}
err = app.db.ChangeSettings(app, u, &s)
if err != nil {
if reqJSON {
return err
}
if err, ok := err.(impart.HTTPError); ok {
addSessionFlash(app, w, r, err.Message, nil)
}
} else {
// Successful update.
if reqJSON {
return impart.WriteSuccess(w, u, http.StatusOK)
}
if s.IsLogOut {
redirectTo = "/me/logout"
} else {
sess.Values[cookieUserVal] = u.Cookie()
addSessionFlash(app, w, r, "Account updated.", nil)
}
}
w.Header().Set("Location", redirectTo)
w.WriteHeader(http.StatusFound)
return nil
}
func updatePassphrase(app *App, w http.ResponseWriter, r *http.Request) error {
accessToken := r.Header.Get("Authorization")
if accessToken == "" {
return ErrNoAccessToken
}
curPass := r.FormValue("current")
newPass := r.FormValue("new")
// Ensure a new password is given (always required)
if newPass == "" {
return impart.HTTPError{http.StatusBadRequest, "Provide a new password."}
}
userID, sudo := app.db.GetUserIDPrivilege(accessToken)
if userID == -1 {
return ErrBadAccessToken
}
// Ensure a current password is given if the access token doesn't have sudo
// privileges.
if !sudo && curPass == "" {
return impart.HTTPError{http.StatusBadRequest, "Provide current password."}
}
// Hash the new password
hashedPass, err := auth.HashPass([]byte(newPass))
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
}
// Do update
err = app.db.ChangePassphrase(userID, sudo, curPass, hashedPass)
if err != nil {
return err
}
return impart.WriteSuccess(w, struct{}{}, http.StatusOK)
}
func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
var c *Collection
var err error
vars := mux.Vars(r)
alias := vars["collection"]
if alias != "" {
c, err = app.db.GetCollection(alias)
if err != nil {
return err
}
if c.OwnerID != u.ID {
return ErrCollectionNotFound
}
}
topPosts, err := app.db.GetTopPosts(u, alias)
if err != nil {
log.Error("Unable to get top posts: %v", err)
return err
}
flashes, _ := getSessionFlashes(app, w, r, nil)
titleStats := ""
if c != nil {
titleStats = c.DisplayTitle() + " "
}
obj := struct {
*UserPage
VisitsBlog string
Collection *Collection
TopPosts *[]PublicPost
APFollowers int
}{
UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes),
VisitsBlog: alias,
Collection: c,
TopPosts: topPosts,
}
if app.cfg.App.Federation {
folls, err := app.db.GetAPFollowers(c)
if err != nil {
return err
}
obj.APFollowers = len(*folls)
}
showUserPage(w, "stats", obj)
return nil
}
func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
fullUser, err := app.db.GetUserByID(u.ID)
if err != nil {
log.Error("Unable to get user for settings: %s", err)
return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."}
}
passIsSet, err := app.db.IsUserPassSet(u.ID)
if err != nil {
log.Error("Unable to get isUserPassSet for settings: %s", err)
return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."}
}
flashes, _ := getSessionFlashes(app, w, r, nil)
obj := struct {
*UserPage
Email string
HasPass bool
IsLogOut bool
}{
UserPage: NewUserPage(app, r, u, "Account Settings", flashes),
Email: fullUser.EmailClear(app.keys),
HasPass: passIsSet,
IsLogOut: r.FormValue("logout") == "1",
}
showUserPage(w, "settings", obj)
return nil
}
func saveTempInfo(app *App, key, val string, r *http.Request, w http.ResponseWriter) error {
session, err := app.sessionStore.Get(r, "t")
if err != nil {
return ErrInternalCookieSession
}
session.Values[key] = val
err = session.Save(r, w)
if err != nil {
log.Error("Couldn't saveTempInfo for key-val (%s:%s): %v", key, val, err)
}
return err
}
func getTempInfo(app *App, key string, r *http.Request, w http.ResponseWriter) string {
session, err := app.sessionStore.Get(r, "t")
if err != nil {
return ""
}
// Get the information
var s = ""
var ok bool
if s, ok = session.Values[key].(string); !ok {
return ""
}
// Delete cookie
session.Options.MaxAge = -1
err = session.Save(r, w)
if err != nil {
log.Error("Couldn't erase temp data for key %s: %v", key, err)
}
// Return value
return s
}
diff --git a/admin.go b/admin.go
index 99e1672..fe19ad5 100644
--- a/admin.go
+++ b/admin.go
@@ -1,452 +1,453 @@
/*
* Copyright © 2018-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 writefreely
import (
"database/sql"
"fmt"
"github.com/gogits/gogs/pkg/tool"
"github.com/gorilla/mux"
"github.com/writeas/impart"
"github.com/writeas/web-core/auth"
"github.com/writeas/web-core/log"
"github.com/writeas/writefreely/config"
"net/http"
"runtime"
"strconv"
"time"
)
var (
appStartTime = time.Now()
sysStatus systemStatus
)
const adminUsersPerPage = 30
type systemStatus struct {
Uptime string
NumGoroutine int
// General statistics.
MemAllocated string // bytes allocated and still in use
MemTotal string // bytes allocated (even if freed)
MemSys string // bytes obtained from system (sum of XxxSys below)
Lookups uint64 // number of pointer lookups
MemMallocs uint64 // number of mallocs
MemFrees uint64 // number of frees
// Main allocation heap statistics.
HeapAlloc string // bytes allocated and still in use
HeapSys string // bytes obtained from system
HeapIdle string // bytes in idle spans
HeapInuse string // bytes in non-idle span
HeapReleased string // bytes released to the OS
HeapObjects uint64 // total number of allocated objects
// Low-level fixed-size structure allocator statistics.
// Inuse is bytes used now.
// Sys is bytes obtained from system.
StackInuse string // bootstrap stacks
StackSys string
MSpanInuse string // mspan structures
MSpanSys string
MCacheInuse string // mcache structures
MCacheSys string
BuckHashSys string // profiling bucket hash table
GCSys string // GC metadata
OtherSys string // other system allocations
// Garbage collector statistics.
NextGC string // next run in HeapAlloc time (bytes)
LastGC string // last run in absolute time (ns)
PauseTotalNs string
PauseNs string // circular buffer of recent GC pause times, most recent at [(NumGC+255)%256]
NumGC uint32
}
type inspectedCollection struct {
CollectionObj
Followers int
LastPost string
}
type instanceContent struct {
ID string
Type string
Title sql.NullString
Content string
Updated time.Time
}
func (c instanceContent) UpdatedFriendly() string {
/*
// TODO: accept a locale in this method and use that for the format
var loc monday.Locale = monday.LocaleEnUS
return monday.Format(u.Created, monday.DateTimeFormatsByLocale[loc], loc)
*/
return c.Updated.Format("January 2, 2006, 3:04 PM")
}
func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
updateAppStats()
p := struct {
*UserPage
SysStatus systemStatus
Config config.AppCfg
Message, ConfigMessage string
}{
UserPage: NewUserPage(app, r, u, "Admin", nil),
SysStatus: sysStatus,
Config: app.cfg.App,
Message: r.FormValue("m"),
ConfigMessage: r.FormValue("cm"),
}
showUserPage(w, "admin", p)
return nil
}
func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
p := struct {
*UserPage
Config config.AppCfg
Message string
Users *[]User
CurPage int
TotalUsers int64
TotalPages []int
}{
UserPage: NewUserPage(app, r, u, "Users", nil),
Config: app.cfg.App,
Message: r.FormValue("m"),
}
p.TotalUsers = app.db.GetAllUsersCount()
ttlPages := p.TotalUsers / adminUsersPerPage
p.TotalPages = []int{}
for i := 1; i <= int(ttlPages); i++ {
p.TotalPages = append(p.TotalPages, i)
}
var err error
p.CurPage, err = strconv.Atoi(r.FormValue("p"))
if err != nil || p.CurPage < 1 {
p.CurPage = 1
} else if p.CurPage > int(ttlPages) {
p.CurPage = int(ttlPages)
}
p.Users, err = app.db.GetAllUsers(uint(p.CurPage))
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get users: %v", err)}
}
showUserPage(w, "users", p)
return nil
}
func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
username := vars["username"]
if username == "" {
return impart.HTTPError{http.StatusFound, "/admin/users"}
}
p := struct {
*UserPage
Config config.AppCfg
Message string
User *User
Colls []inspectedCollection
LastPost string
TotalPosts int64
}{
Config: app.cfg.App,
Message: r.FormValue("m"),
Colls: []inspectedCollection{},
}
var err error
p.User, err = app.db.GetUserForAuth(username)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user: %v", err)}
}
p.UserPage = NewUserPage(app, r, u, p.User.Username, nil)
p.TotalPosts = app.db.GetUserPostsCount(p.User.ID)
lp, err := app.db.GetUserLastPostTime(p.User.ID)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user's last post time: %v", err)}
}
if lp != nil {
p.LastPost = lp.Format("January 2, 2006, 3:04 PM")
}
colls, err := app.db.GetCollections(p.User)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user's collections: %v", err)}
}
for _, c := range *colls {
ic := inspectedCollection{
CollectionObj: CollectionObj{Collection: c},
}
if app.cfg.App.Federation {
folls, err := app.db.GetAPFollowers(&c)
if err == nil {
// TODO: handle error here (at least log it)
ic.Followers = len(*folls)
}
}
app.db.GetPostsCount(&ic.CollectionObj, true)
lp, err := app.db.GetCollectionLastPostTime(c.ID)
if err != nil {
log.Error("Didn't get last post time for collection %d: %v", c.ID, err)
}
if lp != nil {
ic.LastPost = lp.Format("January 2, 2006, 3:04 PM")
}
p.Colls = append(p.Colls, ic)
}
showUserPage(w, "view-user", p)
return nil
}
func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
p := struct {
*UserPage
Config config.AppCfg
Message string
Pages []*instanceContent
}{
UserPage: NewUserPage(app, r, u, "Pages", nil),
Config: app.cfg.App,
Message: r.FormValue("m"),
}
var err error
p.Pages, err = app.db.GetInstancePages()
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get pages: %v", err)}
}
// Add in default pages
var hasAbout, hasPrivacy bool
for i, c := range p.Pages {
if hasAbout && hasPrivacy {
break
}
if c.ID == "about" {
hasAbout = true
if !c.Title.Valid {
p.Pages[i].Title = defaultAboutTitle(app.cfg)
}
} else if c.ID == "privacy" {
hasPrivacy = true
if !c.Title.Valid {
p.Pages[i].Title = defaultPrivacyTitle()
}
}
}
if !hasAbout {
p.Pages = append(p.Pages, &instanceContent{
ID: "about",
Title: defaultAboutTitle(app.cfg),
Content: defaultAboutPage(app.cfg),
Updated: defaultPageUpdatedTime,
})
}
if !hasPrivacy {
p.Pages = append(p.Pages, &instanceContent{
ID: "privacy",
Title: defaultPrivacyTitle(),
Content: defaultPrivacyPolicy(app.cfg),
Updated: defaultPageUpdatedTime,
})
}
showUserPage(w, "pages", p)
return nil
}
func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
slug := vars["slug"]
if slug == "" {
return impart.HTTPError{http.StatusFound, "/admin/pages"}
}
p := struct {
*UserPage
Config config.AppCfg
Message string
Banner *instanceContent
Content *instanceContent
}{
Config: app.cfg.App,
Message: r.FormValue("m"),
}
var err error
// Get pre-defined pages, or select slug
if slug == "about" {
p.Content, err = getAboutPage(app)
} else if slug == "privacy" {
p.Content, err = getPrivacyPage(app)
} else if slug == "landing" {
p.Banner, err = getLandingBanner(app)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get banner: %v", err)}
}
p.Content, err = getLandingBody(app)
p.Content.ID = "landing"
} else {
p.Content, err = app.db.GetDynamicContent(slug)
}
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get page: %v", err)}
}
title := "New page"
if p.Content != nil {
title = "Edit " + p.Content.ID
} else {
p.Content = &instanceContent{}
}
p.UserPage = NewUserPage(app, r, u, title, nil)
showUserPage(w, "view-page", p)
return nil
}
func handleAdminUpdateSite(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
id := vars["page"]
// Validate
if id != "about" && id != "privacy" && id != "landing" {
return impart.HTTPError{http.StatusNotFound, "No such page."}
}
var err error
m := ""
if id == "landing" {
// Handle special landing page
err = app.db.UpdateDynamicContent("landing-banner", "", r.FormValue("banner"), "section")
if err != nil {
m = "?m=" + err.Error()
return impart.HTTPError{http.StatusFound, "/admin/page/" + id + m}
}
err = app.db.UpdateDynamicContent("landing-body", "", r.FormValue("content"), "section")
} else {
// Update page
err = app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "page")
}
if err != nil {
m = "?m=" + err.Error()
}
return impart.HTTPError{http.StatusFound, "/admin/page/" + id + m}
}
func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *http.Request) error {
apper.App().cfg.App.SiteName = r.FormValue("site_name")
apper.App().cfg.App.SiteDesc = r.FormValue("site_desc")
apper.App().cfg.App.Landing = r.FormValue("landing")
apper.App().cfg.App.OpenRegistration = r.FormValue("open_registration") == "on"
mul, err := strconv.Atoi(r.FormValue("min_username_len"))
if err == nil {
apper.App().cfg.App.MinUsernameLen = mul
}
mb, err := strconv.Atoi(r.FormValue("max_blogs"))
if err == nil {
apper.App().cfg.App.MaxBlogs = mb
}
apper.App().cfg.App.Federation = r.FormValue("federation") == "on"
apper.App().cfg.App.PublicStats = r.FormValue("public_stats") == "on"
apper.App().cfg.App.Private = r.FormValue("private") == "on"
apper.App().cfg.App.LocalTimeline = r.FormValue("local_timeline") == "on"
if apper.App().cfg.App.LocalTimeline && apper.App().timeline == nil {
log.Info("Initializing local timeline...")
initLocalTimeline(apper.App())
}
apper.App().cfg.App.UserInvites = r.FormValue("user_invites")
if apper.App().cfg.App.UserInvites == "none" {
apper.App().cfg.App.UserInvites = ""
}
+ apper.App().cfg.App.DefaultVisibility = r.FormValue("default_visibility")
m := "?cm=Configuration+saved."
err = apper.SaveConfig(apper.App().cfg)
if err != nil {
m = "?cm=" + err.Error()
}
return impart.HTTPError{http.StatusFound, "/admin" + m + "#config"}
}
func updateAppStats() {
sysStatus.Uptime = tool.TimeSincePro(appStartTime)
m := new(runtime.MemStats)
runtime.ReadMemStats(m)
sysStatus.NumGoroutine = runtime.NumGoroutine()
sysStatus.MemAllocated = tool.FileSize(int64(m.Alloc))
sysStatus.MemTotal = tool.FileSize(int64(m.TotalAlloc))
sysStatus.MemSys = tool.FileSize(int64(m.Sys))
sysStatus.Lookups = m.Lookups
sysStatus.MemMallocs = m.Mallocs
sysStatus.MemFrees = m.Frees
sysStatus.HeapAlloc = tool.FileSize(int64(m.HeapAlloc))
sysStatus.HeapSys = tool.FileSize(int64(m.HeapSys))
sysStatus.HeapIdle = tool.FileSize(int64(m.HeapIdle))
sysStatus.HeapInuse = tool.FileSize(int64(m.HeapInuse))
sysStatus.HeapReleased = tool.FileSize(int64(m.HeapReleased))
sysStatus.HeapObjects = m.HeapObjects
sysStatus.StackInuse = tool.FileSize(int64(m.StackInuse))
sysStatus.StackSys = tool.FileSize(int64(m.StackSys))
sysStatus.MSpanInuse = tool.FileSize(int64(m.MSpanInuse))
sysStatus.MSpanSys = tool.FileSize(int64(m.MSpanSys))
sysStatus.MCacheInuse = tool.FileSize(int64(m.MCacheInuse))
sysStatus.MCacheSys = tool.FileSize(int64(m.MCacheSys))
sysStatus.BuckHashSys = tool.FileSize(int64(m.BuckHashSys))
sysStatus.GCSys = tool.FileSize(int64(m.GCSys))
sysStatus.OtherSys = tool.FileSize(int64(m.OtherSys))
sysStatus.NextGC = tool.FileSize(int64(m.NextGC))
sysStatus.LastGC = fmt.Sprintf("%.1fs", float64(time.Now().UnixNano()-int64(m.LastGC))/1000/1000/1000)
sysStatus.PauseTotalNs = fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/1000/1000/1000)
sysStatus.PauseNs = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC+255)%256])/1000/1000/1000)
sysStatus.NumGC = m.NumGC
}
func adminResetPassword(app *App, u *User, newPass string) error {
hashedPass, err := auth.HashPass([]byte(newPass))
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not create password hash: %v", err)}
}
err = app.db.ChangePassphrase(u.ID, true, "", hashedPass)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not update passphrase: %v", err)}
}
return nil
}
diff --git a/app.go b/app.go
index 79b7145..dec0ef2 100644
--- a/app.go
+++ b/app.go
@@ -1,760 +1,804 @@
/*
* Copyright © 2018-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 writefreely
import (
+ "crypto/tls"
"database/sql"
"fmt"
"html/template"
"io/ioutil"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"
"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/impart"
"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/key"
"github.com/writeas/writefreely/migrations"
"github.com/writeas/writefreely/page"
+ "golang.org/x/crypto/acme/autocert"
)
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.10.0"
// DEPRECATED VARS
isSingleUser bool
)
// App holds data and configuration for an individual WriteFreely instance.
type App struct {
router *mux.Router
shttp *http.ServeMux
db *datastore
cfg *config.Config
cfgFile string
keys *key.Keychain
sessionStore *sessions.CookieStore
formDecoder *schema.Decoder
timeline *localTimeline
}
// DB returns the App's datastore
func (app *App) DB() *datastore {
return app.db
}
// Router returns the App's router
func (app *App) Router() *mux.Router {
return app.router
}
// Config returns the App's current configuration.
func (app *App) Config() *config.Config {
return app.cfg
}
// SetConfig updates the App's Config to the given value.
func (app *App) SetConfig(cfg *config.Config) {
app.cfg = cfg
}
// SetKeys updates the App's Keychain to the given value.
func (app *App) SetKeys(k *key.Keychain) {
app.keys = k
}
// Apper is the interface for getting data into and out of a WriteFreely
// instance (or "App").
//
// App returns the App for the current instance.
//
// LoadConfig reads an app configuration into the App, returning any error
// encountered.
//
// SaveConfig persists the current App configuration.
//
// LoadKeys reads the App's encryption keys and loads them into its
// key.Keychain.
type Apper interface {
App() *App
LoadConfig() error
SaveConfig(*config.Config) error
LoadKeys() error
+
+ ReqLog(r *http.Request, status int, timeSince time.Duration) string
}
// App returns the App
func (app *App) App() *App {
return app
}
// LoadConfig loads and parses a config file.
func (app *App) LoadConfig() error {
log.Info("Loading %s configuration...", app.cfgFile)
cfg, err := config.Load(app.cfgFile)
if err != nil {
log.Error("Unable to load configuration: %v", err)
os.Exit(1)
return err
}
app.cfg = cfg
return nil
}
// SaveConfig saves the given Config to disk -- namely, to the App's cfgFile.
func (app *App) SaveConfig(c *config.Config) error {
return config.Save(c, app.cfgFile)
}
// LoadKeys reads all needed keys from disk into the App. In order to use the
// configured `Server.KeysParentDir`, you must call initKeyPaths(App) before
// this.
func (app *App) LoadKeys() error {
var err error
app.keys = &key.Keychain{}
if debugging {
log.Info(" %s", emailKeyPath)
}
app.keys.EmailKey, err = ioutil.ReadFile(emailKeyPath)
if err != nil {
return err
}
if debugging {
log.Info(" %s", cookieAuthKeyPath)
}
app.keys.CookieAuthKey, err = ioutil.ReadFile(cookieAuthKeyPath)
if err != nil {
return err
}
if debugging {
log.Info(" %s", cookieKeyPath)
}
app.keys.CookieKey, err = ioutil.ReadFile(cookieKeyPath)
if err != nil {
return err
}
return nil
}
+func (app *App) ReqLog(r *http.Request, status int, timeSince time.Duration) string {
+ return fmt.Sprintf("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, timeSince, r.UserAgent())
+}
+
// 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
forceLanding := r.FormValue("landing") == "1"
if !forceLanding {
// Show correct page based on user auth status and configured landing path
u := getUserSession(app, r)
if u != nil {
// User is logged in, so show the Pad
return handleViewPad(app, w, r)
}
if land := app.cfg.App.LandingPath(); land != "/" {
return impart.HTTPError{http.StatusFound, land}
}
}
p := struct {
page.StaticPage
Flashes []template.HTML
Banner template.HTML
Content template.HTML
ForcedLanding bool
}{
StaticPage: pageForReq(app, r),
ForcedLanding: forceLanding,
}
banner, err := getLandingBanner(app)
if err != nil {
log.Error("unable to get landing banner: %v", err)
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get banner: %v", err)}
}
p.Banner = template.HTML(applyMarkdown([]byte(banner.Content), ""))
content, err := getLandingBody(app)
if err != nil {
log.Error("unable to get landing content: %v", err)
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get content: %v", err)}
}
p.Content = template.HTML(applyMarkdown([]byte(content.Content), ""))
// 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
ContentTitle string
Content template.HTML
PlainContent string
Updated string
AboutStats *InstanceStats
}{
StaticPage: pageForReq(app, r),
}
if r.URL.Path == "/about" || r.URL.Path == "/privacy" {
var c *instanceContent
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, err = getPrivacyPage(app)
}
if err != nil {
return err
}
p.ContentTitle = c.Title.String
p.Content = template.HTML(applyMarkdown([]byte(c.Content), ""))
p.PlainContent = shortPostDescription(stripmd.Strip(c.Content))
if !c.Updated.IsZero() {
p.Updated = c.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
}
}
p.CanViewReader = !app.cfg.App.Private || u != nil
return p
}
var fileRegex = regexp.MustCompile("/([^/]*\\.[^/]*)$")
// Initialize loads the app configuration and initializes templates, keys,
// session, route handlers, and the database connection.
func Initialize(apper Apper, debug bool) (*App, error) {
debugging = debug
apper.LoadConfig()
// Load templates
err := InitTemplates(apper.App().Config())
if err != nil {
return nil, fmt.Errorf("load templates: %s", err)
}
// Load keys and set up session
initKeyPaths(apper.App()) // TODO: find a better way to do this, since it's unneeded in all Apper implementations
err = InitKeys(apper)
if err != nil {
return nil, fmt.Errorf("init keys: %s", err)
}
apper.App().InitSession()
apper.App().InitDecoder()
err = ConnectToDatabase(apper.App())
if err != nil {
return nil, fmt.Errorf("connect to DB: %s", err)
}
// Handle local timeline, if enabled
if apper.App().cfg.App.LocalTimeline {
log.Info("Initializing local timeline...")
initLocalTimeline(apper.App())
}
return apper.App(), nil
}
func Serve(app *App, r *mux.Router) {
log.Info("Going to serve...")
isSingleUser = app.cfg.App.SingleUser
app.cfg.Server.Dev = debugging
// 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)
}()
// Start web application server
var bindAddress = app.cfg.Server.Bind
if bindAddress == "" {
bindAddress = "localhost"
}
var err error
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) {
+ if app.cfg.Server.Autocert {
+ m := &autocert.Manager{
+ Prompt: autocert.AcceptTOS,
+ Cache: autocert.DirCache(app.cfg.Server.TLSCertPath),
+ }
+ host, err := url.Parse(app.cfg.App.Host)
+ if err != nil {
+ log.Error("[WARNING] Unable to parse configured host! %s", err)
+ log.Error(`[WARNING] ALL hosts are allowed, which can open you to an attack where
+clients connect to a server by IP address and pretend to be asking for an
+incorrect host name, and cause you to reach the CA's rate limit for certificate
+requests. We recommend supplying a valid host name.`)
+ log.Info("Using autocert on ANY host")
+ } else {
+ log.Info("Using autocert on host %s", host.Host)
+ m.HostPolicy = autocert.HostWhitelist(host.Host)
+ }
+ s := &http.Server{
+ Addr: ":https",
+ Handler: r,
+ TLSConfig: &tls.Config{
+ GetCertificate: m.GetCertificate,
+ },
+ }
+ s.SetKeepAlivesEnabled(false)
+
+ go func() {
+ log.Info("Serving redirects on http://%s:80", bindAddress)
+ err = http.ListenAndServe(":80", m.HTTPHandler(nil))
+ log.Error("Unable to start redirect server: %v", err)
+ }()
+
+ log.Info("Serving on https://%s:443", bindAddress)
+ log.Info("---")
+ err = s.ListenAndServeTLS("", "")
+ } else {
+ go func() {
+ log.Info("Serving redirects on http://%s:80", bindAddress)
+ err = http.ListenAndServe(fmt.Sprintf("%s:80", bindAddress), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, app.cfg.App.Host, http.StatusMovedPermanently)
}))
- log.Error("Unable to start redirect server: %v", err)
- }()
+ log.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, r)
+ log.Info("Serving on https://%s:443", bindAddress)
+ log.Info("Using manual certificates")
+ log.Info("---")
+ err = http.ListenAndServeTLS(fmt.Sprintf("%s:443", bindAddress), app.cfg.Server.TLSCertPath, app.cfg.Server.TLSKeyPath, r)
+ }
} 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), r)
}
if err != nil {
log.Error("Unable to start: %v", err)
os.Exit(1)
}
}
func (app *App) InitDecoder() {
// TODO: do this at the package level, instead of the App level
// Initialize modules
app.formDecoder = schema.NewDecoder()
app.formDecoder.RegisterConverter(converter.NullJSONString{}, converter.ConvertJSONNullString)
app.formDecoder.RegisterConverter(converter.NullJSONBool{}, converter.ConvertJSONNullBool)
app.formDecoder.RegisterConverter(sql.NullString{}, converter.ConvertSQLNullString)
app.formDecoder.RegisterConverter(sql.NullBool{}, converter.ConvertSQLNullBool)
app.formDecoder.RegisterConverter(sql.NullInt64{}, converter.ConvertSQLNullInt64)
app.formDecoder.RegisterConverter(sql.NullFloat64{}, converter.ConvertSQLNullFloat64)
}
// ConnectToDatabase validates and connects to the configured database, then
// tests the connection.
func ConnectToDatabase(app *App) error {
// Check database configuration
if app.cfg.Database.Type == driverMySQL && (app.cfg.Database.User == "" || app.cfg.Database.Password == "") {
return fmt.Errorf("Database user or password not set.")
}
if app.cfg.Database.Host == "" {
app.cfg.Database.Host = "localhost"
}
if app.cfg.Database.Database == "" {
app.cfg.Database.Database = "writefreely"
}
// TODO: check err
connectToDatabase(app)
// Test database connection
err := app.db.Ping()
if err != nil {
return fmt.Errorf("Database ping failed: %s", err)
}
return nil
}
// OutputVersion prints out the version of the application.
func OutputVersion() {
fmt.Println(serverSoftware + " " + softwareVer)
}
// NewApp creates a new app instance.
func NewApp(cfgFile string) *App {
return &App{
cfgFile: cfgFile,
}
}
// CreateConfig creates a default configuration and saves it to the app's cfgFile.
func CreateConfig(app *App) error {
log.Info("Creating configuration...")
c := config.New()
log.Info("Saving configuration %s...", app.cfgFile)
err := config.Save(c, app.cfgFile)
if err != nil {
return fmt.Errorf("Unable to save configuration: %v", err)
}
return nil
}
// DoConfig runs the interactive configuration process.
func DoConfig(app *App, configSections string) {
if configSections == "" {
configSections = "server db app"
}
// let's check there aren't any garbage in the list
configSectionsArray := strings.Split(configSections, " ")
for _, element := range configSectionsArray {
if element != "server" && element != "db" && element != "app" {
log.Error("Invalid argument to --sections. Valid arguments are only \"server\", \"db\" and \"app\"")
os.Exit(1)
}
}
d, err := config.Configure(app.cfgFile, configSections)
if err != nil {
log.Error("Unable to configure: %v", err)
os.Exit(1)
}
app.cfg = d.Config
connectToDatabase(app)
defer shutdown(app)
if !app.db.DatabaseInitialized() {
err = adminInitDatabase(app)
if err != nil {
log.Error(err.Error())
os.Exit(1)
}
} else {
log.Info("Database already initialized.")
}
if d.User != nil {
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)
+ err = app.db.CreateUser(app.cfg, 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)
}
// GenerateKeyFiles creates app encryption keys and saves them into the configured KeysParentDir.
func GenerateKeyFiles(app *App) error {
// Read keys path from config
app.LoadConfig()
// Create keys dir if it doesn't exist yet
fullKeysDir := filepath.Join(app.cfg.Server.KeysParentDir, keysDir)
if _, err := os.Stat(fullKeysDir); os.IsNotExist(err) {
err = os.Mkdir(fullKeysDir, 0700)
if err != nil {
return err
}
}
// Generate keys
initKeyPaths(app)
// TODO: use something like https://github.com/hashicorp/go-multierror to return errors
var keyErrs error
err := generateKey(emailKeyPath)
if err != nil {
keyErrs = err
}
err = generateKey(cookieAuthKeyPath)
if err != nil {
keyErrs = err
}
err = generateKey(cookieKeyPath)
if err != nil {
keyErrs = err
}
return keyErrs
}
// CreateSchema creates all database tables needed for the application.
func CreateSchema(apper Apper) error {
apper.LoadConfig()
connectToDatabase(apper.App())
defer shutdown(apper.App())
err := adminInitDatabase(apper.App())
if err != nil {
return err
}
return nil
}
// Migrate runs all necessary database migrations.
func Migrate(apper Apper) error {
apper.LoadConfig()
connectToDatabase(apper.App())
defer shutdown(apper.App())
err := migrations.Migrate(migrations.NewDatastore(apper.App().db.DB, apper.App().db.driverName))
if err != nil {
return fmt.Errorf("migrate: %s", err)
}
return nil
}
// ResetPassword runs the interactive password reset process.
func ResetPassword(apper Apper, username string) error {
// Connect to the database
apper.LoadConfig()
connectToDatabase(apper.App())
defer shutdown(apper.App())
// Fetch user
u, err := apper.App().db.GetUserForAuth(username)
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(apper.App(), u, newPass)
if err != nil {
log.Error("%s", err)
os.Exit(1)
}
log.Info("Success.")
return nil
}
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()
}
// CreateUser creates a new admin or normal user from the given credentials.
func CreateUser(apper Apper, username, password string, isAdmin bool) error {
// Create an admin user with --create-admin
apper.LoadConfig()
connectToDatabase(apper.App())
defer shutdown(apper.App())
// Ensure an admin / first user doesn't already exist
firstUser, _ := apper.App().db.GetUserByID(1)
if isAdmin {
// Abort if trying to create admin user, but one already exists
if firstUser != nil {
return fmt.Errorf("Admin user already exists (%s). Create a regular user with: writefreely --create-user", firstUser.Username)
}
} else {
// Abort if trying to create regular user, but no admin exists yet
if firstUser == nil {
return fmt.Errorf("No admin user exists yet. Create an admin first with: writefreely --create-admin")
}
}
// Create the user
// Normalize and validate username
desiredUsername := username
username = getSlug(username, "")
usernameDesc := username
if username != desiredUsername {
usernameDesc += " (originally: " + desiredUsername + ")"
}
if !author.IsValidUsername(apper.App().cfg, username) {
return fmt.Errorf("Username %s is invalid, reserved, or shorter than configured minimum length (%d characters).", usernameDesc, apper.App().cfg.App.MinUsernameLen)
}
// Hash the password
hashedPass, err := auth.HashPass([]byte(password))
if err != nil {
return fmt.Errorf("Unable to hash password: %v", err)
}
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 = apper.App().db.CreateUser(u, desiredUsername)
+ err = apper.App().db.CreateUser(apper.App().Config(), u, desiredUsername)
if err != nil {
return fmt.Errorf("Unable to create user: %s", err)
}
log.Info("Done!")
return nil
}
func adminInitDatabase(app *App) error {
schemaFileName := "schema.sql"
if app.cfg.Database.Type == driverSQLite {
schemaFileName = "sqlite.sql"
}
schema, err := Asset(schemaFileName)
if err != nil {
return fmt.Errorf("Unable to load schema file: %v", err)
}
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("Initializing appmigrations table...")
err = migrations.SetInitialMigrations(migrations.NewDatastore(app.db.DB, app.db.driverName))
if err != nil {
return fmt.Errorf("Unable to set initial migrations: %v", err)
}
log.Info("Running migrations...")
err = migrations.Migrate(migrations.NewDatastore(app.db.DB, app.db.driverName))
if err != nil {
return fmt.Errorf("migrate: %s", err)
}
log.Info("Done.")
return nil
}
diff --git a/collections.go b/collections.go
index 1a8ceca..aee74f7 100644
--- a/collections.go
+++ b/collections.go
@@ -1,1062 +1,1078 @@
/*
* 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"
"encoding/json"
"fmt"
"html/template"
"math"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"unicode"
"github.com/gorilla/mux"
"github.com/writeas/impart"
"github.com/writeas/web-core/activitystreams"
"github.com/writeas/web-core/auth"
"github.com/writeas/web-core/bots"
"github.com/writeas/web-core/log"
waposts "github.com/writeas/web-core/posts"
"github.com/writeas/writefreely/author"
+ "github.com/writeas/writefreely/config"
"github.com/writeas/writefreely/page"
)
type (
// TODO: add Direction to db
// TODO: add Language to db
Collection struct {
ID int64 `datastore:"id" json:"-"`
Alias string `datastore:"alias" schema:"alias" json:"alias"`
Title string `datastore:"title" schema:"title" json:"title"`
Description string `datastore:"description" schema:"description" json:"description"`
Direction string `schema:"dir" json:"dir,omitempty"`
Language string `schema:"lang" json:"lang,omitempty"`
StyleSheet string `datastore:"style_sheet" schema:"style_sheet" json:"style_sheet"`
Script string `datastore:"script" schema:"script" json:"script,omitempty"`
Public bool `datastore:"public" json:"public"`
Visibility collVisibility `datastore:"private" json:"-"`
Format string `datastore:"format" json:"format,omitempty"`
Views int64 `json:"views"`
OwnerID int64 `datastore:"owner_id" json:"-"`
PublicOwner bool `datastore:"public_owner" json:"-"`
URL string `json:"url,omitempty"`
db *datastore
hostName string
}
CollectionObj struct {
Collection
TotalPosts int `json:"total_posts"`
Owner *User `json:"owner,omitempty"`
Posts *[]PublicPost `json:"posts,omitempty"`
}
DisplayCollection struct {
*CollectionObj
Prefix string
IsTopLevel bool
CurrentPage int
TotalPages int
Format *CollectionFormat
}
SubmittedCollection struct {
// Data used for updating a given collection
ID int64
OwnerID uint64
// Form helpers
PreferURL string `schema:"prefer_url" json:"prefer_url"`
Privacy int `schema:"privacy" json:"privacy"`
Pass string `schema:"password" json:"password"`
MathJax bool `schema:"mathjax" json:"mathjax"`
Handle string `schema:"handle" json:"handle"`
// Actual collection values updated in the DB
Alias *string `schema:"alias" json:"alias"`
Title *string `schema:"title" json:"title"`
Description *string `schema:"description" json:"description"`
StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"`
Script *sql.NullString `schema:"script" json:"script"`
Visibility *int `schema:"visibility" json:"public"`
Format *sql.NullString `schema:"format" json:"format"`
}
CollectionFormat struct {
Format string
}
collectionReq struct {
// Information about the collection request itself
prefix, alias, domain string
isCustomDomain bool
// User-related fields
isCollOwner bool
}
)
func (sc *SubmittedCollection) FediverseHandle() string {
if sc.Handle == "" {
return apCustomHandleDefault
}
return getSlug(sc.Handle, "")
}
// collVisibility represents the visibility level for the collection.
type collVisibility int
// Visibility levels. Values are bitmasks, stored in the database as
// decimal numbers. If adding types, append them to this list. If removing,
// replace the desired visibility with a new value.
const CollUnlisted collVisibility = 0
const (
CollPublic collVisibility = 1 << iota
CollPrivate
CollProtected
)
+var collVisibilityStrings = map[string]collVisibility{
+ "unlisted": CollUnlisted,
+ "public": CollPublic,
+ "private": CollPrivate,
+ "protected": CollProtected,
+}
+
+func defaultVisibility(cfg *config.Config) collVisibility {
+ vis, ok := collVisibilityStrings[cfg.App.DefaultVisibility]
+ if !ok {
+ vis = CollUnlisted
+ }
+ return vis
+}
+
func (cf *CollectionFormat) Ascending() bool {
return cf.Format == "novel"
}
func (cf *CollectionFormat) ShowDates() bool {
return cf.Format == "blog"
}
func (cf *CollectionFormat) PostsPerPage() int {
if cf.Format == "novel" {
return postsPerPage
}
return postsPerPage
}
// Valid returns whether or not a format value is valid.
func (cf *CollectionFormat) Valid() bool {
return cf.Format == "blog" ||
cf.Format == "novel" ||
cf.Format == "notebook"
}
// NewFormat creates a new CollectionFormat object from the Collection.
func (c *Collection) NewFormat() *CollectionFormat {
cf := &CollectionFormat{Format: c.Format}
// Fill in default format
if cf.Format == "" {
cf.Format = "blog"
}
return cf
}
func (c *Collection) IsUnlisted() bool {
return c.Visibility == 0
}
func (c *Collection) IsPrivate() bool {
return c.Visibility&CollPrivate != 0
}
func (c *Collection) IsProtected() bool {
return c.Visibility&CollProtected != 0
}
func (c *Collection) IsPublic() bool {
return c.Visibility&CollPublic != 0
}
func (c *Collection) FriendlyVisibility() string {
if c.IsPrivate() {
return "Private"
}
if c.IsPublic() {
return "Public"
}
if c.IsProtected() {
return "Password-protected"
}
return "Unlisted"
}
func (c *Collection) ShowFooterBranding() bool {
// TODO: implement this setting
return true
}
// CanonicalURL returns a fully-qualified URL to the collection.
func (c *Collection) CanonicalURL() string {
return c.RedirectingCanonicalURL(false)
}
func (c *Collection) DisplayCanonicalURL() string {
us := c.CanonicalURL()
u, err := url.Parse(us)
if err != nil {
return us
}
p := u.Path
if p == "/" {
p = ""
}
return u.Hostname() + p
}
func (c *Collection) RedirectingCanonicalURL(isRedir bool) string {
+ if c.hostName == "" {
+ // If this is true, the human programmers screwed up. So ask for a bug report and fail, fail, fail
+ log.Error("[PROGRAMMER ERROR] WARNING: Collection.hostName is empty! Federation and many other things will fail! If you're seeing this in the wild, please report this bug and let us know what you were doing just before this: https://github.com/writeas/writefreely/issues/new?template=bug_report.md")
+ }
if isSingleUser {
return c.hostName + "/"
}
return fmt.Sprintf("%s/%s/", c.hostName, c.Alias)
}
// PrevPageURL provides a full URL for the previous page of collection posts,
// returning a /page/N result for pages >1
func (c *Collection) PrevPageURL(prefix string, n int, tl bool) string {
u := ""
if n == 2 {
// Previous page is 1; no need for /page/ prefix
if prefix == "" {
u = "/"
}
// Else leave off trailing slash
} else {
u = fmt.Sprintf("/page/%d", n-1)
}
if tl {
return u
}
return "/" + prefix + c.Alias + u
}
// NextPageURL provides a full URL for the next page of collection posts
func (c *Collection) NextPageURL(prefix string, n int, tl bool) string {
if tl {
return fmt.Sprintf("/page/%d", n+1)
}
return fmt.Sprintf("/%s%s/page/%d", prefix, c.Alias, n+1)
}
func (c *Collection) DisplayTitle() string {
if c.Title != "" {
return c.Title
}
return c.Alias
}
func (c *Collection) StyleSheetDisplay() template.CSS {
return template.CSS(c.StyleSheet)
}
// ForPublic modifies the Collection for public consumption, such as via
// the API.
func (c *Collection) ForPublic() {
c.URL = c.CanonicalURL()
}
var isAvatarChar = regexp.MustCompile("[a-z0-9]").MatchString
func (c *Collection) PersonObject(ids ...int64) *activitystreams.Person {
accountRoot := c.FederatedAccount()
p := activitystreams.NewPerson(accountRoot)
p.URL = c.CanonicalURL()
uname := c.Alias
p.PreferredUsername = uname
p.Name = c.DisplayTitle()
p.Summary = c.Description
if p.Name != "" {
if av := c.AvatarURL(); av != "" {
p.Icon = activitystreams.Image{
Type: "Image",
MediaType: "image/png",
URL: av,
}
}
}
collID := c.ID
if len(ids) > 0 {
collID = ids[0]
}
pub, priv := c.db.GetAPActorKeys(collID)
if pub != nil {
p.AddPubKey(pub)
p.SetPrivKey(priv)
}
return p
}
func (c *Collection) AvatarURL() string {
fl := string(unicode.ToLower([]rune(c.DisplayTitle())[0]))
if !isAvatarChar(fl) {
return ""
}
return c.hostName + "/img/avatars/" + fl + ".png"
}
func (c *Collection) FederatedAPIBase() string {
return c.hostName + "/"
}
func (c *Collection) FederatedAccount() string {
accountUser := c.Alias
return c.FederatedAPIBase() + "api/collections/" + accountUser
}
func (c *Collection) RenderMathJax() bool {
return c.db.CollectionHasAttribute(c.ID, "render_mathjax")
}
func newCollection(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type"))
alias := r.FormValue("alias")
title := r.FormValue("title")
var missingParams, accessToken string
var u *User
c := struct {
Alias string `json:"alias" schema:"alias"`
Title string `json:"title" schema:"title"`
Web bool `json:"web" schema:"web"`
}{}
if reqJSON {
// Decode JSON request
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&c)
if err != nil {
log.Error("Couldn't parse post update JSON request: %v\n", err)
return ErrBadJSON
}
} else {
// TODO: move form parsing to formDecoder
c.Alias = alias
c.Title = title
}
if c.Alias == "" {
if c.Title != "" {
// If only a title was given, just use it to generate the alias.
c.Alias = getSlug(c.Title, "")
} else {
missingParams += "`alias` "
}
}
if c.Title == "" {
missingParams += "`title` "
}
if missingParams != "" {
return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Parameter(s) %srequired.", missingParams)}
}
+ var userID int64
if reqJSON && !c.Web {
accessToken = r.Header.Get("Authorization")
if accessToken == "" {
return ErrNoAccessToken
}
+ userID = app.db.GetUserID(accessToken)
+ if userID == -1 {
+ return ErrBadAccessToken
+ }
} else {
u = getUserSession(app, r)
if u == nil {
return ErrNotLoggedIn
}
+ userID = u.ID
}
if !author.IsValidUsername(app.cfg, c.Alias) {
return impart.HTTPError{http.StatusPreconditionFailed, "Collection alias isn't valid."}
}
- var coll *Collection
- var err error
- if accessToken != "" {
- coll, err = app.db.CreateCollectionFromToken(c.Alias, c.Title, accessToken)
- if err != nil {
- // TODO: handle this
- return err
- }
- } else {
- coll, err = app.db.CreateCollection(c.Alias, c.Title, u.ID)
- if err != nil {
- // TODO: handle this
- return err
- }
+ coll, err := app.db.CreateCollection(app.cfg, c.Alias, c.Title, userID)
+ if err != nil {
+ // TODO: handle this
+ return err
}
res := &CollectionObj{Collection: *coll}
if reqJSON {
return impart.WriteSuccess(w, res, http.StatusCreated)
}
redirectTo := "/me/c/"
// TODO: redirect to pad when necessary
return impart.HTTPError{http.StatusFound, redirectTo}
}
func apiCheckCollectionPermissions(app *App, r *http.Request, c *Collection) (int64, error) {
accessToken := r.Header.Get("Authorization")
var userID int64 = -1
if accessToken != "" {
userID = app.db.GetUserID(accessToken)
}
isCollOwner := userID == c.OwnerID
if c.IsPrivate() && !isCollOwner {
// Collection is private, but user isn't authenticated
return -1, ErrCollectionNotFound
}
if c.IsProtected() {
// TODO: check access token
return -1, ErrCollectionUnauthorizedRead
}
return userID, nil
}
// fetchCollection handles the API endpoint for retrieving collection data.
func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error {
accept := r.Header.Get("Accept")
if strings.Contains(accept, "application/activity+json") {
return handleFetchCollectionActivities(app, w, r)
}
vars := mux.Vars(r)
alias := vars["alias"]
// TODO: move this logic into a common getCollection function
// Get base Collection data
c, err := app.db.GetCollection(alias)
if err != nil {
return err
}
c.hostName = app.cfg.App.Host
// Redirect users who aren't requesting JSON
reqJSON := IsJSON(r.Header.Get("Content-Type"))
if !reqJSON {
return impart.HTTPError{http.StatusFound, c.CanonicalURL()}
}
// Check permissions
userID, err := apiCheckCollectionPermissions(app, r, c)
if err != nil {
return err
}
isCollOwner := userID == c.OwnerID
// Fetch extra data about the Collection
res := &CollectionObj{Collection: *c}
if c.PublicOwner {
u, err := app.db.GetUserByID(res.OwnerID)
if err != nil {
// Log the error and just continue
log.Error("Error getting user for collection: %v", err)
} else {
res.Owner = u
}
}
app.db.GetPostsCount(res, isCollOwner)
// Strip non-public information
res.Collection.ForPublic()
return impart.WriteSuccess(w, res, http.StatusOK)
}
// fetchCollectionPosts handles an API endpoint for retrieving a collection's
// posts.
func fetchCollectionPosts(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
alias := vars["alias"]
c, err := app.db.GetCollection(alias)
if err != nil {
return err
}
c.hostName = app.cfg.App.Host
// Check permissions
userID, err := apiCheckCollectionPermissions(app, r, c)
if err != nil {
return err
}
isCollOwner := userID == c.OwnerID
// Get page
page := 1
if p := r.FormValue("page"); p != "" {
pInt, _ := strconv.Atoi(p)
if pInt > 0 {
page = pInt
}
}
posts, err := app.db.GetPosts(c, page, isCollOwner, false, false)
if err != nil {
return err
}
coll := &CollectionObj{Collection: *c, Posts: posts}
app.db.GetPostsCount(coll, isCollOwner)
// Strip non-public information
coll.Collection.ForPublic()
// Transform post bodies if needed
if r.FormValue("body") == "html" {
for _, p := range *coll.Posts {
p.Content = waposts.ApplyMarkdown([]byte(p.Content))
}
}
return impart.WriteSuccess(w, coll, http.StatusOK)
}
type CollectionPage struct {
page.StaticPage
*DisplayCollection
IsCustomDomain bool
IsWelcome bool
IsOwner bool
CanPin bool
Username string
Collections *[]Collection
PinnedPosts *[]PublicPost
}
func (c *CollectionObj) ScriptDisplay() template.JS {
return template.JS(c.Script)
}
var jsSourceCommentReg = regexp.MustCompile("(?m)^// src:(.+)$")
func (c *CollectionObj) ExternalScripts() []template.URL {
scripts := []template.URL{}
if c.Script == "" {
return scripts
}
matches := jsSourceCommentReg.FindAllStringSubmatch(c.Script, -1)
for _, m := range matches {
scripts = append(scripts, template.URL(strings.TrimSpace(m[1])))
}
return scripts
}
func (c *CollectionObj) CanShowScript() bool {
return false
}
func processCollectionRequest(cr *collectionReq, vars map[string]string, w http.ResponseWriter, r *http.Request) error {
cr.prefix = vars["prefix"]
cr.alias = vars["collection"]
// Normalize the URL, redirecting user to consistent post URL
if cr.alias != strings.ToLower(cr.alias) {
return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s/", strings.ToLower(cr.alias))}
}
return nil
}
// processCollectionPermissions checks the permissions for the given
// collectionReq, returning a Collection if access is granted; otherwise this
// renders any necessary collection pages, for example, if requesting a custom
// domain that doesn't yet have a collection associated, or if a collection
// requires a password. In either case, this will return nil, nil -- thus both
// values should ALWAYS be checked to determine whether or not to continue.
func processCollectionPermissions(app *App, cr *collectionReq, u *User, w http.ResponseWriter, r *http.Request) (*Collection, error) {
// Display collection if this is a collection
var c *Collection
var err error
if app.cfg.App.SingleUser {
c, err = app.db.GetCollectionByID(1)
} else {
c, err = app.db.GetCollection(cr.alias)
}
// TODO: verify we don't reveal the existence of a private collection with redirection
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
if err.Status == http.StatusNotFound {
if cr.isCustomDomain {
// User is on the site from a custom domain
//tErr := pages["404-domain.tmpl"].ExecuteTemplate(w, "base", pageForHost(page.StaticPage{}, r))
//if tErr != nil {
//log.Error("Unable to render 404-domain page: %v", err)
//}
return nil, nil
}
if len(cr.alias) >= minIDLen && len(cr.alias) <= maxIDLen {
// Alias is within post ID range, so just be sure this isn't a post
if app.db.PostIDExists(cr.alias) {
// TODO: use StatusFound for vanity post URLs when we implement them
return nil, impart.HTTPError{http.StatusMovedPermanently, "/" + cr.alias}
}
}
// Redirect if necessary
newAlias := app.db.GetCollectionRedirect(cr.alias)
if newAlias != "" {
return nil, impart.HTTPError{http.StatusFound, "/" + newAlias + "/"}
}
}
}
return nil, err
}
c.hostName = app.cfg.App.Host
// Update CollectionRequest to reflect owner status
cr.isCollOwner = u != nil && u.ID == c.OwnerID
// Check permissions
if !cr.isCollOwner {
if c.IsPrivate() {
return nil, ErrCollectionNotFound
} else if c.IsProtected() {
uname := ""
if u != nil {
uname = u.Username
}
// See if we've authorized this collection
authd := isAuthorizedForCollection(app, c.Alias, r)
if !authd {
p := struct {
page.StaticPage
*CollectionObj
Username string
Next string
Flashes []template.HTML
}{
StaticPage: pageForReq(app, r),
CollectionObj: &CollectionObj{Collection: *c},
Username: uname,
Next: r.FormValue("g"),
Flashes: []template.HTML{},
}
// Get owner information
p.CollectionObj.Owner, err = app.db.GetUserByID(c.OwnerID)
if err != nil {
// Log the error and just continue
log.Error("Error getting user for collection: %v", err)
}
flashes, _ := getSessionFlashes(app, w, r, nil)
for _, flash := range flashes {
p.Flashes = append(p.Flashes, template.HTML(flash))
}
err = templates["password-collection"].ExecuteTemplate(w, "password-collection", p)
if err != nil {
log.Error("Unable to render password-collection: %v", err)
return nil, err
}
return nil, nil
}
}
}
return c, nil
}
func checkUserForCollection(app *App, cr *collectionReq, r *http.Request, isPostReq bool) (*User, error) {
u := getUserSession(app, r)
return u, nil
}
func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCollection {
coll := &DisplayCollection{
CollectionObj: &CollectionObj{Collection: *c},
CurrentPage: page,
Prefix: cr.prefix,
IsTopLevel: isSingleUser,
Format: c.NewFormat(),
}
c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner)
return coll
}
func getCollectionPage(vars map[string]string) int {
page := 1
var p int
p, _ = strconv.Atoi(vars["page"])
if p > 0 {
page = p
}
return page
}
// handleViewCollection displays the requested Collection
func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
cr := &collectionReq{}
err := processCollectionRequest(cr, vars, w, r)
if err != nil {
return err
}
u, err := checkUserForCollection(app, cr, r, false)
if err != nil {
return err
}
page := getCollectionPage(vars)
c, err := processCollectionPermissions(app, cr, u, w, r)
if c == nil || err != nil {
return err
}
// Serve ActivityStreams data now, if requested
if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
ac := c.PersonObject()
ac.Context = []interface{}{activitystreams.Namespace}
return impart.RenderActivityJSON(w, ac, http.StatusOK)
}
// Fetch extra data about the Collection
// TODO: refactor out this logic, shared in collection.go:fetchCollection()
coll := newDisplayCollection(c, cr, page)
coll.TotalPages = int(math.Ceil(float64(coll.TotalPosts) / float64(coll.Format.PostsPerPage())))
if coll.TotalPages > 0 && page > coll.TotalPages {
redirURL := fmt.Sprintf("/page/%d", coll.TotalPages)
if !app.cfg.App.SingleUser {
redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL)
}
return impart.HTTPError{http.StatusFound, redirURL}
}
coll.Posts, _ = app.db.GetPosts(c, page, cr.isCollOwner, false, false)
// Serve collection
displayPage := CollectionPage{
DisplayCollection: coll,
StaticPage: pageForReq(app, r),
IsCustomDomain: cr.isCustomDomain,
IsWelcome: r.FormValue("greeting") != "",
}
var owner *User
if u != nil {
displayPage.Username = u.Username
displayPage.IsOwner = u.ID == coll.OwnerID
if displayPage.IsOwner {
// Add in needed information for users viewing their own collection
owner = u
displayPage.CanPin = true
pubColls, err := app.db.GetPublishableCollections(owner)
if err != nil {
log.Error("unable to fetch collections: %v", err)
}
displayPage.Collections = pubColls
}
}
if owner == nil {
// Current user doesn't own collection; retrieve owner information
owner, err = app.db.GetUserByID(coll.OwnerID)
if err != nil {
// Log the error and just continue
log.Error("Error getting user for collection: %v", err)
}
}
displayPage.Owner = owner
coll.Owner = displayPage.Owner
// Add more data
// TODO: fix this mess of collections inside collections
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj)
err = templates["collection"].ExecuteTemplate(w, "collection", displayPage)
if err != nil {
log.Error("Unable to render collection index: %v", err)
}
// Update collection view count
go func() {
// Don't update if owner is viewing the collection.
if u != nil && u.ID == coll.OwnerID {
return
}
// Only update for human views
if r.Method == "HEAD" || bots.IsBot(r.UserAgent()) {
return
}
_, err := app.db.Exec("UPDATE collections SET view_count = view_count + 1 WHERE id = ?", coll.ID)
if err != nil {
log.Error("Unable to update collections count: %v", err)
}
}()
return err
}
func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
tag := vars["tag"]
cr := &collectionReq{}
err := processCollectionRequest(cr, vars, w, r)
if err != nil {
return err
}
u, err := checkUserForCollection(app, cr, r, false)
if err != nil {
return err
}
page := getCollectionPage(vars)
c, err := processCollectionPermissions(app, cr, u, w, r)
if c == nil || err != nil {
return err
}
coll := newDisplayCollection(c, cr, page)
coll.Posts, _ = app.db.GetPostsTagged(c, tag, page, cr.isCollOwner)
if coll.Posts != nil && len(*coll.Posts) == 0 {
return ErrCollectionPageNotFound
}
// Serve collection
displayPage := struct {
CollectionPage
Tag string
}{
CollectionPage: CollectionPage{
DisplayCollection: coll,
StaticPage: pageForReq(app, r),
IsCustomDomain: cr.isCustomDomain,
},
Tag: tag,
}
var owner *User
if u != nil {
displayPage.Username = u.Username
displayPage.IsOwner = u.ID == coll.OwnerID
if displayPage.IsOwner {
// Add in needed information for users viewing their own collection
owner = u
displayPage.CanPin = true
pubColls, err := app.db.GetPublishableCollections(owner)
if err != nil {
log.Error("unable to fetch collections: %v", err)
}
displayPage.Collections = pubColls
}
}
if owner == nil {
// Current user doesn't own collection; retrieve owner information
owner, err = app.db.GetUserByID(coll.OwnerID)
if err != nil {
// Log the error and just continue
log.Error("Error getting user for collection: %v", err)
}
}
displayPage.Owner = owner
coll.Owner = displayPage.Owner
// Add more data
// TODO: fix this mess of collections inside collections
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj)
err = templates["collection-tags"].ExecuteTemplate(w, "collection-tags", displayPage)
if err != nil {
log.Error("Unable to render collection tag page: %v", err)
}
return nil
}
func handleCollectionPostRedirect(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
slug := vars["slug"]
cr := &collectionReq{}
err := processCollectionRequest(cr, vars, w, r)
if err != nil {
return err
}
// Normalize the URL, redirecting user to consistent post URL
loc := fmt.Sprintf("/%s", slug)
if !app.cfg.App.SingleUser {
loc = fmt.Sprintf("/%s/%s", cr.alias, slug)
}
return impart.HTTPError{http.StatusFound, loc}
}
func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type"))
vars := mux.Vars(r)
collAlias := vars["alias"]
isWeb := r.FormValue("web") == "1"
var u *User
if reqJSON && !isWeb {
// Ensure an access token was given
accessToken := r.Header.Get("Authorization")
u = &User{}
u.ID = app.db.GetUserID(accessToken)
if u.ID == -1 {
return ErrBadAccessToken
}
} else {
u = getUserSession(app, r)
if u == nil {
return ErrNotLoggedIn
}
}
if r.Method == "DELETE" {
err := app.db.DeleteCollection(collAlias, u.ID)
if err != nil {
// TODO: if not HTTPError, report error to admin
log.Error("Unable to delete collection: %s", err)
return err
}
addSessionFlash(app, w, r, "Deleted your blog, "+collAlias+".", nil)
return impart.HTTPError{Status: http.StatusNoContent}
}
c := SubmittedCollection{OwnerID: uint64(u.ID)}
var err error
if reqJSON {
// Decode JSON request
decoder := json.NewDecoder(r.Body)
err = decoder.Decode(&c)
if err != nil {
log.Error("Couldn't parse collection update JSON request: %v\n", err)
return ErrBadJSON
}
} else {
err = r.ParseForm()
if err != nil {
log.Error("Couldn't parse collection update form request: %v\n", err)
return ErrBadFormData
}
err = app.formDecoder.Decode(&c, r.PostForm)
if err != nil {
log.Error("Couldn't decode collection update form request: %v\n", err)
return ErrBadFormData
}
}
err = app.db.UpdateCollection(&c, collAlias)
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
if reqJSON {
return err
}
addSessionFlash(app, w, r, err.Message, nil)
return impart.HTTPError{http.StatusFound, "/me/c/" + collAlias}
} else {
log.Error("Couldn't update collection: %v\n", err)
return err
}
}
if reqJSON {
return impart.WriteSuccess(w, struct {
}{}, http.StatusOK)
}
addSessionFlash(app, w, r, "Blog updated!", nil)
return impart.HTTPError{http.StatusFound, "/me/c/" + collAlias}
}
// collectionAliasFromReq takes a request and returns the collection alias
// if it can be ascertained, as well as whether or not the collection uses a
// custom domain.
func collectionAliasFromReq(r *http.Request) string {
vars := mux.Vars(r)
alias := vars["subdomain"]
isSubdomain := alias != ""
if !isSubdomain {
// Fall back to write.as/{collection} since this isn't a custom domain
alias = vars["collection"]
}
return alias
}
func handleWebCollectionUnlock(app *App, w http.ResponseWriter, r *http.Request) error {
var readReq struct {
Alias string `schema:"alias" json:"alias"`
Pass string `schema:"password" json:"password"`
Next string `schema:"to" json:"to"`
}
// Get params
if impart.ReqJSON(r) {
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&readReq)
if err != nil {
log.Error("Couldn't parse readReq JSON request: %v\n", err)
return ErrBadJSON
}
} else {
err := r.ParseForm()
if err != nil {
log.Error("Couldn't parse readReq form request: %v\n", err)
return ErrBadFormData
}
err = app.formDecoder.Decode(&readReq, r.PostForm)
if err != nil {
log.Error("Couldn't decode readReq form request: %v\n", err)
return ErrBadFormData
}
}
if readReq.Alias == "" {
return impart.HTTPError{http.StatusBadRequest, "Need a collection `alias` to read."}
}
if readReq.Pass == "" {
return impart.HTTPError{http.StatusBadRequest, "Please supply a password."}
}
var collHashedPass []byte
err := app.db.QueryRow("SELECT password FROM collectionpasswords INNER JOIN collections ON id = collection_id WHERE alias = ?", readReq.Alias).Scan(&collHashedPass)
if err != nil {
if err == sql.ErrNoRows {
log.Error("No collectionpassword found when trying to read collection %s", readReq.Alias)
return impart.HTTPError{http.StatusInternalServerError, "Something went very wrong. The humans have been alerted."}
}
return err
}
if !auth.Authenticated(collHashedPass, []byte(readReq.Pass)) {
return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."}
}
// Success; set cookie
session, err := app.sessionStore.Get(r, blogPassCookieName)
if err == nil {
session.Values[readReq.Alias] = true
err = session.Save(r, w)
if err != nil {
log.Error("Didn't save unlocked blog '%s': %v", readReq.Alias, err)
}
}
next := "/" + readReq.Next
if !app.cfg.App.SingleUser {
next = "/" + readReq.Alias + next
}
return impart.HTTPError{http.StatusFound, next}
}
func isAuthorizedForCollection(app *App, alias string, r *http.Request) bool {
authd := false
session, err := app.sessionStore.Get(r, blogPassCookieName)
if err == nil {
_, authd = session.Values[alias]
}
return authd
}
diff --git a/config/config.go b/config/config.go
index 46b3e19..2d86c9e 100644
--- a/config/config.go
+++ b/config/config.go
@@ -1,180 +1,184 @@
/*
* Copyright © 2018-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 config holds and assists in the configuration of a writefreely instance.
package config
import (
"gopkg.in/ini.v1"
"strings"
)
const (
// FileName is the default configuration file name
FileName = "config.ini"
UserNormal UserType = "user"
UserAdmin = "admin"
)
type (
UserType string
// ServerCfg holds values that affect how the HTTP server runs
ServerCfg struct {
HiddenHost string `ini:"hidden_host"`
Port int `ini:"port"`
Bind string `ini:"bind"`
TLSCertPath string `ini:"tls_cert_path"`
TLSKeyPath string `ini:"tls_key_path"`
+ Autocert bool `ini:"autocert"`
TemplatesParentDir string `ini:"templates_parent_dir"`
StaticParentDir string `ini:"static_parent_dir"`
PagesParentDir string `ini:"pages_parent_dir"`
KeysParentDir string `ini:"keys_parent_dir"`
Dev bool `ini:"-"`
}
// DatabaseCfg holds values that determine how the application connects to a datastore
DatabaseCfg struct {
Type string `ini:"type"`
FileName string `ini:"filename"`
User string `ini:"username"`
Password string `ini:"password"`
Database string `ini:"database"`
Host string `ini:"host"`
Port int `ini:"port"`
}
// AppCfg holds values that affect how the application functions
AppCfg struct {
SiteName string `ini:"site_name"`
SiteDesc string `ini:"site_description"`
Host string `ini:"host"`
// Site appearance
Theme string `ini:"theme"`
JSDisabled bool `ini:"disable_js"`
WebFonts bool `ini:"webfonts"`
Landing string `ini:"landing"`
WFModesty bool `ini:"wf_modesty"`
// Users
SingleUser bool `ini:"single_user"`
OpenRegistration bool `ini:"open_registration"`
MinUsernameLen int `ini:"min_username_len"`
MaxBlogs int `ini:"max_blogs"`
// Federation
Federation bool `ini:"federation"`
PublicStats bool `ini:"public_stats"`
// Access
Private bool `ini:"private"`
// Additional functions
LocalTimeline bool `ini:"local_timeline"`
UserInvites string `ini:"user_invites"`
+
+ // Defaults
+ DefaultVisibility string `ini:"default_visibility"`
}
// Config holds the complete configuration for running a writefreely instance
Config struct {
Server ServerCfg `ini:"server"`
Database DatabaseCfg `ini:"database"`
App AppCfg `ini:"app"`
}
)
// New creates a new Config with sane defaults
func New() *Config {
c := &Config{
Server: ServerCfg{
Port: 8080,
Bind: "localhost", /* IPV6 support when not using localhost? */
},
App: AppCfg{
Host: "http://localhost:8080",
Theme: "write",
WebFonts: true,
SingleUser: true,
MinUsernameLen: 3,
MaxBlogs: 1,
Federation: true,
PublicStats: true,
},
}
c.UseMySQL(true)
return c
}
// UseMySQL resets the Config's Database to use default values for a MySQL setup.
func (cfg *Config) UseMySQL(fresh bool) {
cfg.Database.Type = "mysql"
if fresh {
cfg.Database.Host = "localhost"
cfg.Database.Port = 3306
}
}
// UseSQLite resets the Config's Database to use default values for a SQLite setup.
func (cfg *Config) UseSQLite(fresh bool) {
cfg.Database.Type = "sqlite3"
if fresh {
cfg.Database.FileName = "writefreely.db"
}
}
// IsSecureStandalone returns whether or not the application is running as a
// standalone server with TLS enabled.
func (cfg *Config) IsSecureStandalone() bool {
return cfg.Server.Port == 443 && cfg.Server.TLSCertPath != "" && cfg.Server.TLSKeyPath != ""
}
func (ac *AppCfg) LandingPath() string {
if !strings.HasPrefix(ac.Landing, "/") {
return "/" + ac.Landing
}
return ac.Landing
}
// Load reads the given configuration file, then parses and returns it as a Config.
func Load(fname string) (*Config, error) {
if fname == "" {
fname = FileName
}
cfg, err := ini.Load(fname)
if err != nil {
return nil, err
}
// Parse INI file
uc := &Config{}
err = cfg.MapTo(uc)
if err != nil {
return nil, err
}
return uc, nil
}
// Save writes the given Config to the given file.
func Save(uc *Config, fname string) error {
cfg := ini.Empty()
err := ini.ReflectFrom(cfg, uc)
if err != nil {
return err
}
if fname == "" {
fname = FileName
}
return cfg.SaveTo(fname)
}
diff --git a/config/setup.go b/config/setup.go
index b1c0c37..fd5a632 100644
--- a/config/setup.go
+++ b/config/setup.go
@@ -1,373 +1,382 @@
/*
* 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 config
import (
"fmt"
"github.com/fatih/color"
"github.com/manifoldco/promptui"
"github.com/mitchellh/go-wordwrap"
"github.com/writeas/web-core/auth"
"strconv"
"strings"
)
type SetupData struct {
User *UserCreation
Config *Config
}
func Configure(fname string, configSections string) (*SetupData, error) {
data := &SetupData{}
var err error
if fname == "" {
fname = FileName
}
data.Config, err = Load(fname)
var action string
isNewCfg := false
if err != nil {
fmt.Printf("No %s configuration yet. Creating new.\n", fname)
data.Config = New()
action = "generate"
isNewCfg = true
} else {
fmt.Printf("Loaded configuration %s.\n", fname)
action = "update"
}
title := color.New(color.Bold, color.BgGreen).PrintFunc()
intro := color.New(color.Bold, color.FgWhite).PrintlnFunc()
fmt.Println()
intro(" ✍ WriteFreely Configuration ✍")
fmt.Println()
fmt.Println(wordwrap.WrapString(" This quick configuration process will "+action+" the application's config file, "+fname+".\n\n It validates your input along the way, so you can be sure any future errors aren't caused by a bad configuration. If you'd rather configure your server manually, instead run: writefreely --create-config and edit that file.", 75))
fmt.Println()
tmpls := &promptui.PromptTemplates{
Success: "{{ . | bold | faint }}: ",
}
selTmpls := &promptui.SelectTemplates{
Selected: fmt.Sprintf(`{{.Label}} {{ . | faint }}`),
}
var selPrompt promptui.Select
var prompt promptui.Prompt
if strings.Contains(configSections, "server") {
title(" Server setup ")
fmt.Println()
// Environment selection
selPrompt = promptui.Select{
Templates: selTmpls,
Label: "Environment",
Items: []string{"Development", "Production, standalone", "Production, behind reverse proxy"},
}
_, envType, err := selPrompt.Run()
if err != nil {
return data, err
}
isDevEnv := envType == "Development"
isStandalone := envType == "Production, standalone"
data.Config.Server.Dev = isDevEnv
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)"},
+ Items: []string{"Insecure (port 80)", "Secure (port 443), manual certificate", "Secure (port 443), auto certificate"},
}
sel, _, err := selPrompt.Run()
if err != nil {
return data, err
}
if sel == 0 {
+ data.Config.Server.Autocert = false
data.Config.Server.Port = 80
data.Config.Server.TLSCertPath = ""
data.Config.Server.TLSKeyPath = ""
- } else if sel == 1 {
+ } else if sel == 1 || sel == 2 {
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
+ data.Config.Server.Autocert = sel == 2
+
+ if sel == 1 {
+ // Manual certificate configuration
+ 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 {
+ // Automatic certificate
+ data.Config.Server.TLSCertPath = "certs"
+ data.Config.Server.TLSKeyPath = "certs"
}
}
} else {
data.Config.Server.TLSCertPath = ""
data.Config.Server.TLSKeyPath = ""
}
fmt.Println()
}
if strings.Contains(configSections, "db") {
title(" Database setup ")
fmt.Println()
selPrompt = promptui.Select{
Templates: selTmpls,
Label: "Database driver",
Items: []string{"MySQL", "SQLite"},
}
sel, _, err := selPrompt.Run()
if err != nil {
return data, err
}
if sel == 0 {
// Configure for MySQL
data.Config.UseMySQL(isNewCfg)
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Username",
Validate: validateNonEmpty,
Default: data.Config.Database.User,
}
data.Config.Database.User, err = prompt.Run()
if err != nil {
return data, err
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Password",
Validate: validateNonEmpty,
Default: data.Config.Database.Password,
Mask: '*',
}
data.Config.Database.Password, err = prompt.Run()
if err != nil {
return data, err
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Database name",
Validate: validateNonEmpty,
Default: data.Config.Database.Database,
}
data.Config.Database.Database, err = prompt.Run()
if err != nil {
return data, err
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Host",
Validate: validateNonEmpty,
Default: data.Config.Database.Host,
}
data.Config.Database.Host, err = prompt.Run()
if err != nil {
return data, err
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Port",
Validate: validatePort,
Default: fmt.Sprintf("%d", data.Config.Database.Port),
}
dbPort, err := prompt.Run()
if err != nil {
return data, err
}
data.Config.Database.Port, _ = strconv.Atoi(dbPort) // Ignore error, as we've already validated number
} else if sel == 1 {
// Configure for SQLite
data.Config.UseSQLite(isNewCfg)
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Filename",
Validate: validateNonEmpty,
Default: data.Config.Database.FileName,
}
data.Config.Database.FileName, err = prompt.Run()
if err != nil {
return data, err
}
}
fmt.Println()
}
if strings.Contains(configSections, "app") {
title(" App setup ")
fmt.Println()
selPrompt = promptui.Select{
Templates: selTmpls,
Label: "Site type",
Items: []string{"Single user blog", "Multi-user instance"},
}
_, usersType, err := selPrompt.Run()
if err != nil {
return data, err
}
data.Config.App.SingleUser = usersType == "Single user blog"
if data.Config.App.SingleUser {
data.User = &UserCreation{}
// prompt for username
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Admin username",
Validate: validateNonEmpty,
}
data.User.Username, err = prompt.Run()
if err != nil {
return data, err
}
// prompt for password
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Admin password",
Validate: validateNonEmpty,
}
newUserPass, err := prompt.Run()
if err != nil {
return data, err
}
data.User.HashedPass, err = auth.HashPass([]byte(newUserPass))
if err != nil {
return data, err
}
}
siteNameLabel := "Instance name"
if data.Config.App.SingleUser {
siteNameLabel = "Blog name"
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: siteNameLabel,
Validate: validateNonEmpty,
Default: data.Config.App.SiteName,
}
data.Config.App.SiteName, err = prompt.Run()
if err != nil {
return data, err
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Public URL",
Validate: validateDomain,
Default: data.Config.App.Host,
}
data.Config.App.Host, err = prompt.Run()
if err != nil {
return data, err
}
if !data.Config.App.SingleUser {
selPrompt = promptui.Select{
Templates: selTmpls,
Label: "Registration",
Items: []string{"Open", "Closed"},
}
_, regType, err := selPrompt.Run()
if err != nil {
return data, err
}
data.Config.App.OpenRegistration = regType == "Open"
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Max blogs per user",
Default: fmt.Sprintf("%d", data.Config.App.MaxBlogs),
}
maxBlogs, err := prompt.Run()
if err != nil {
return data, err
}
data.Config.App.MaxBlogs, _ = strconv.Atoi(maxBlogs) // Ignore error, as we've already validated number
}
selPrompt = promptui.Select{
Templates: selTmpls,
Label: "Federation",
Items: []string{"Enabled", "Disabled"},
}
_, fedType, err := selPrompt.Run()
if err != nil {
return data, err
}
data.Config.App.Federation = fedType == "Enabled"
if data.Config.App.Federation {
selPrompt = promptui.Select{
Templates: selTmpls,
Label: "Federation usage stats",
Items: []string{"Public", "Private"},
}
_, fedStatsType, err := selPrompt.Run()
if err != nil {
return data, err
}
data.Config.App.PublicStats = fedStatsType == "Public"
selPrompt = promptui.Select{
Templates: selTmpls,
Label: "Instance metadata privacy",
Items: []string{"Public", "Private"},
}
_, fedStatsType, err = selPrompt.Run()
if err != nil {
return data, err
}
data.Config.App.Private = fedStatsType == "Private"
}
}
return data, Save(data.Config, fname)
}
diff --git a/database.go b/database.go
index 1ea9872..34c5234 100644
--- a/database.go
+++ b/database.go
@@ -1,2430 +1,2433 @@
/*
* 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"
"fmt"
"net/http"
"strings"
"time"
"github.com/guregu/null"
"github.com/guregu/null/zero"
uuid "github.com/nu7hatch/gouuid"
"github.com/writeas/impart"
"github.com/writeas/nerds/store"
"github.com/writeas/web-core/activitypub"
"github.com/writeas/web-core/auth"
"github.com/writeas/web-core/data"
"github.com/writeas/web-core/id"
"github.com/writeas/web-core/log"
"github.com/writeas/web-core/query"
"github.com/writeas/writefreely/author"
+ "github.com/writeas/writefreely/config"
"github.com/writeas/writefreely/key"
)
const (
mySQLErrDuplicateKey = 1062
driverMySQL = "mysql"
driverSQLite = "sqlite3"
)
var (
SQLiteEnabled bool
)
type writestore interface {
- CreateUser(*User, string) error
+ CreateUser(*config.Config, *User, string) error
UpdateUserEmail(keys *key.Keychain, userID int64, email string) error
UpdateEncryptedUserEmail(int64, []byte) error
GetUserByID(int64) (*User, error)
GetUserForAuth(string) (*User, error)
GetUserForAuthByID(int64) (*User, error)
GetUserNameFromToken(string) (string, error)
GetUserDataFromToken(string) (int64, string, error)
GetAPIUser(header string) (*User, error)
GetUserID(accessToken string) int64
GetUserIDPrivilege(accessToken string) (userID int64, sudo bool)
DeleteToken(accessToken []byte) error
FetchLastAccessToken(userID int64) string
GetAccessToken(userID int64) (string, error)
GetTemporaryAccessToken(userID int64, validSecs int) (string, error)
GetTemporaryOneTimeAccessToken(userID int64, validSecs int, oneTime bool) (string, error)
DeleteAccount(userID int64) (l *string, err error)
ChangeSettings(app *App, u *User, s *userSettings) error
ChangePassphrase(userID int64, sudo bool, curPass string, hashedPass []byte) error
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)
- CreateOwnedPost(post *SubmittedPost, accessToken, collAlias string) (*PublicPost, error)
+ CreateOwnedPost(post *SubmittedPost, accessToken, collAlias, hostName string) (*PublicPost, error)
CreatePost(userID, collID int64, post *SubmittedPost) (*Post, error)
UpdateOwnedPost(post *AuthenticatedPost, userID int64) error
GetEditablePost(id, editToken string) (*PublicPost, error)
PostIDExists(id string) bool
GetPost(id string, collectionID int64) (*PublicPost, error)
GetOwnedPost(id string, ownerID int64) (*PublicPost, error)
GetPostProperty(id string, collectionID int64, property string) (interface{}, error)
- CreateCollectionFromToken(string, string, string) (*Collection, error)
- CreateCollection(string, string, int64) (*Collection, error)
+ CreateCollectionFromToken(*config.Config, string, string, string) (*Collection, error)
+ CreateCollection(*config.Config, string, string, int64) (*Collection, error)
GetCollectionBy(condition string, value interface{}) (*Collection, error)
GetCollection(alias string) (*Collection, error)
GetCollectionForPad(alias string) (*Collection, error)
GetCollectionByID(id int64) (*Collection, error)
UpdateCollection(c *SubmittedCollection, alias string) error
DeleteCollection(alias string, userID int64) error
UpdatePostPinState(pinned bool, postID string, collID, ownerID, pos int64) error
GetLastPinnedPostPos(collID int64) int64
GetPinnedPosts(coll *CollectionObj) (*[]PublicPost, error)
RemoveCollectionRedirect(t *sql.Tx, alias string) error
GetCollectionRedirect(alias string) (new string)
IsCollectionAttributeOn(id int64, attr string) bool
CollectionHasAttribute(id int64, attr string) bool
CanCollect(cpr *ClaimPostRequest, userID int64) bool
AttemptClaim(p *ClaimPostRequest, query string, params []interface{}, slugIdx int) (sql.Result, error)
DispersePosts(userID int64, postIDs []string) (*[]ClaimPostResult, error)
- ClaimPosts(userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error)
+ ClaimPosts(cfg *config.Config, userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error)
GetPostsCount(c *CollectionObj, includeFuture bool)
GetPosts(c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool) (*[]PublicPost, error)
GetPostsTagged(c *Collection, tag string, page int, includeFuture bool) (*[]PublicPost, error)
GetAPFollowers(c *Collection) (*[]RemoteUser, error)
GetAPActorKeys(collectionID int64) ([]byte, []byte)
CreateUserInvite(id string, userID int64, maxUses int, expires *time.Time) error
GetUserInvites(userID int64) (*[]Invite, error)
GetUserInvite(id string) (*Invite, error)
GetUsersInvitedCount(id string) int64
CreateInvitedUser(inviteID string, userID int64) error
GetDynamicContent(id string) (*instanceContent, error)
UpdateDynamicContent(id, title, content, contentType string) error
GetAllUsers(page uint) (*[]User, error)
GetAllUsersCount() int64
GetUserLastPostTime(id int64) (*time.Time, error)
GetCollectionLastPostTime(id int64) (*time.Time, error)
DatabaseInitialized() bool
}
type datastore struct {
*sql.DB
driverName string
}
func (db *datastore) now() string {
if db.driverName == driverSQLite {
return "strftime('%Y-%m-%d %H:%M:%S','now')"
}
return "NOW()"
}
func (db *datastore) clip(field string, l int) string {
if db.driverName == driverSQLite {
return fmt.Sprintf("SUBSTR(%s, 0, %d)", field, l)
}
return fmt.Sprintf("LEFT(%s, %d)", field, l)
}
func (db *datastore) upsert(indexedCols ...string) string {
if db.driverName == driverSQLite {
// NOTE: SQLite UPSERT syntax only works in v3.24.0 (2018-06-04) or later
// Leaving this for whenever we can upgrade and include it in our binary
cc := strings.Join(indexedCols, ", ")
return "ON CONFLICT(" + cc + ") DO UPDATE SET"
}
return "ON DUPLICATE KEY UPDATE"
}
func (db *datastore) dateSub(l int, unit string) string {
if db.driverName == driverSQLite {
return fmt.Sprintf("DATETIME('now', '-%d %s')", l, unit)
}
return fmt.Sprintf("DATE_SUB(NOW(), INTERVAL %d %s)", l, unit)
}
-func (db *datastore) CreateUser(u *User, collectionTitle string) error {
+func (db *datastore) CreateUser(cfg *config.Config, u *User, collectionTitle string) error {
if db.PostIDExists(u.Username) {
return impart.HTTPError{http.StatusConflict, "Invalid collection name."}
}
// New users get a `users` and `collections` row.
t, err := db.Begin()
if err != nil {
return err
}
// 1. Add to `users` table
// NOTE: Assumes User's Password is already hashed!
res, err := t.Exec("INSERT INTO users (username, password, email) VALUES (?, ?, ?)", u.Username, u.HashedPass, u.Email)
if err != nil {
t.Rollback()
if db.isDuplicateKeyErr(err) {
return impart.HTTPError{http.StatusConflict, "Username is already taken."}
}
log.Error("Rolling back users INSERT: %v\n", err)
return err
}
u.ID, err = res.LastInsertId()
if err != nil {
t.Rollback()
log.Error("Rolling back after LastInsertId: %v\n", err)
return err
}
// 2. Create user's Collection
if collectionTitle == "" {
collectionTitle = u.Username
}
- res, err = t.Exec("INSERT INTO collections (alias, title, description, privacy, owner_id, view_count) VALUES (?, ?, ?, ?, ?, ?)", u.Username, collectionTitle, "", CollUnlisted, u.ID, 0)
+ res, err = t.Exec("INSERT INTO collections (alias, title, description, privacy, owner_id, view_count) VALUES (?, ?, ?, ?, ?, ?)", u.Username, collectionTitle, "", defaultVisibility(cfg), u.ID, 0)
if err != nil {
t.Rollback()
if db.isDuplicateKeyErr(err) {
return impart.HTTPError{http.StatusConflict, "Username is already taken."}
}
log.Error("Rolling back collections INSERT: %v\n", err)
return err
}
db.RemoveCollectionRedirect(t, u.Username)
err = t.Commit()
if err != nil {
t.Rollback()
log.Error("Rolling back after Commit(): %v\n", err)
return err
}
return nil
}
// FIXME: We're returning errors inconsistently in this file. Do we use Errorf
// for returned value, or impart?
func (db *datastore) UpdateUserEmail(keys *key.Keychain, userID int64, email string) error {
encEmail, err := data.Encrypt(keys.EmailKey, email)
if err != nil {
return fmt.Errorf("Couldn't encrypt email %s: %s\n", email, err)
}
return db.UpdateEncryptedUserEmail(userID, encEmail)
}
func (db *datastore) UpdateEncryptedUserEmail(userID int64, encEmail []byte) error {
_, err := db.Exec("UPDATE users SET email = ? WHERE id = ?", encEmail, userID)
if err != nil {
return fmt.Errorf("Unable to update user email: %s", err)
}
return nil
}
-func (db *datastore) CreateCollectionFromToken(alias, title, accessToken string) (*Collection, error) {
+func (db *datastore) CreateCollectionFromToken(cfg *config.Config, alias, title, accessToken string) (*Collection, error) {
userID := db.GetUserID(accessToken)
if userID == -1 {
return nil, ErrBadAccessToken
}
- return db.CreateCollection(alias, title, userID)
+ return db.CreateCollection(cfg, alias, title, userID)
}
func (db *datastore) GetUserCollectionCount(userID int64) (uint64, error) {
var collCount uint64
err := db.QueryRow("SELECT COUNT(*) FROM collections WHERE owner_id = ?", userID).Scan(&collCount)
switch {
case err == sql.ErrNoRows:
return 0, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user from database."}
case err != nil:
log.Error("Couldn't get collections count for user %d: %v", userID, err)
return 0, err
}
return collCount, nil
}
-func (db *datastore) CreateCollection(alias, title string, userID int64) (*Collection, error) {
+func (db *datastore) CreateCollection(cfg *config.Config, alias, title string, userID int64) (*Collection, error) {
if db.PostIDExists(alias) {
return nil, impart.HTTPError{http.StatusConflict, "Invalid collection name."}
}
// All good, so create new collection
- res, err := db.Exec("INSERT INTO collections (alias, title, description, privacy, owner_id, view_count) VALUES (?, ?, ?, ?, ?, ?)", alias, title, "", CollUnlisted, userID, 0)
+ res, err := db.Exec("INSERT INTO collections (alias, title, description, privacy, owner_id, view_count) VALUES (?, ?, ?, ?, ?, ?)", alias, title, "", defaultVisibility(cfg), userID, 0)
if err != nil {
if db.isDuplicateKeyErr(err) {
return nil, impart.HTTPError{http.StatusConflict, "Collection already exists."}
}
log.Error("Couldn't add to collections: %v\n", err)
return nil, err
}
c := &Collection{
Alias: alias,
Title: title,
OwnerID: userID,
PublicOwner: false,
+ Public: defaultVisibility(cfg) == CollPublic,
}
c.ID, err = res.LastInsertId()
if err != nil {
log.Error("Couldn't get collection LastInsertId: %v\n", err)
}
return c, nil
}
func (db *datastore) GetUserByID(id int64) (*User, error) {
u := &User{ID: id}
err := db.QueryRow("SELECT username, password, email, created FROM users WHERE id = ?", id).Scan(&u.Username, &u.HashedPass, &u.Email, &u.Created)
switch {
case err == sql.ErrNoRows:
return nil, ErrUserNotFound
case err != nil:
log.Error("Couldn't SELECT user password: %v", err)
return nil, err
}
return u, nil
}
// DoesUserNeedAuth returns true if the user hasn't provided any methods for
// authenticating with the account, such a passphrase or email address.
// Any errors are reported to admin and silently quashed, returning false as the
// result.
func (db *datastore) DoesUserNeedAuth(id int64) bool {
var pass, email []byte
// Find out if user has an email set first
err := db.QueryRow("SELECT password, email FROM users WHERE id = ?", id).Scan(&pass, &email)
switch {
case err == sql.ErrNoRows:
// ERROR. Don't give false positives on needing auth methods
return false
case err != nil:
// ERROR. Don't give false positives on needing auth methods
log.Error("Couldn't SELECT user %d from users: %v", id, err)
return false
}
// User doesn't need auth if there's an email
return len(email) == 0 && len(pass) == 0
}
func (db *datastore) IsUserPassSet(id int64) (bool, error) {
var pass []byte
err := db.QueryRow("SELECT password FROM users WHERE id = ?", id).Scan(&pass)
switch {
case err == sql.ErrNoRows:
return false, nil
case err != nil:
log.Error("Couldn't SELECT user %d from users: %v", id, err)
return false, err
}
return len(pass) > 0, nil
}
func (db *datastore) GetUserForAuth(username string) (*User, error) {
u := &User{Username: username}
err := db.QueryRow("SELECT id, password, email, created FROM users WHERE username = ?", username).Scan(&u.ID, &u.HashedPass, &u.Email, &u.Created)
switch {
case err == sql.ErrNoRows:
// Check if they've entered the wrong, unnormalized username
username = getSlug(username, "")
if username != u.Username {
err = db.QueryRow("SELECT id FROM users WHERE username = ? LIMIT 1", username).Scan(&u.ID)
if err == nil {
return db.GetUserForAuth(username)
}
}
return nil, ErrUserNotFound
case err != nil:
log.Error("Couldn't SELECT user password: %v", err)
return nil, err
}
return u, nil
}
func (db *datastore) GetUserForAuthByID(userID int64) (*User, error) {
u := &User{ID: userID}
err := db.QueryRow("SELECT id, password, email, created FROM users WHERE id = ?", u.ID).Scan(&u.ID, &u.HashedPass, &u.Email, &u.Created)
switch {
case err == sql.ErrNoRows:
return nil, ErrUserNotFound
case err != nil:
log.Error("Couldn't SELECT userForAuthByID: %v", err)
return nil, err
}
return u, nil
}
func (db *datastore) GetUserNameFromToken(accessToken string) (string, error) {
t := auth.GetToken(accessToken)
if len(t) == 0 {
return "", ErrNoAccessToken
}
var oneTime bool
var username string
err := db.QueryRow("SELECT username, one_time FROM accesstokens LEFT JOIN users ON user_id = id WHERE token LIKE ? AND (expires IS NULL OR expires > "+db.now()+")", t).Scan(&username, &oneTime)
switch {
case err == sql.ErrNoRows:
return "", ErrBadAccessToken
case err != nil:
return "", ErrInternalGeneral
}
// Delete token if it was one-time
if oneTime {
db.DeleteToken(t[:])
}
return username, nil
}
func (db *datastore) GetUserDataFromToken(accessToken string) (int64, string, error) {
t := auth.GetToken(accessToken)
if len(t) == 0 {
return 0, "", ErrNoAccessToken
}
var userID int64
var oneTime bool
var username string
err := db.QueryRow("SELECT user_id, username, one_time FROM accesstokens LEFT JOIN users ON user_id = id WHERE token LIKE ? AND (expires IS NULL OR expires > "+db.now()+")", t).Scan(&userID, &username, &oneTime)
switch {
case err == sql.ErrNoRows:
return 0, "", ErrBadAccessToken
case err != nil:
return 0, "", ErrInternalGeneral
}
// Delete token if it was one-time
if oneTime {
db.DeleteToken(t[:])
}
return userID, username, nil
}
func (db *datastore) GetAPIUser(header string) (*User, error) {
uID := db.GetUserID(header)
if uID == -1 {
return nil, fmt.Errorf(ErrUserNotFound.Error())
}
return db.GetUserByID(uID)
}
// GetUserID takes a hexadecimal accessToken, parses it into its binary
// representation, and gets any user ID associated with the token. If no user
// is associated, -1 is returned.
func (db *datastore) GetUserID(accessToken string) int64 {
i, _ := db.GetUserIDPrivilege(accessToken)
return i
}
func (db *datastore) GetUserIDPrivilege(accessToken string) (userID int64, sudo bool) {
t := auth.GetToken(accessToken)
if len(t) == 0 {
return -1, false
}
var oneTime bool
err := db.QueryRow("SELECT user_id, sudo, one_time FROM accesstokens WHERE token LIKE ? AND (expires IS NULL OR expires > "+db.now()+")", t).Scan(&userID, &sudo, &oneTime)
switch {
case err == sql.ErrNoRows:
return -1, false
case err != nil:
return -1, false
}
// Delete token if it was one-time
if oneTime {
db.DeleteToken(t[:])
}
return
}
func (db *datastore) DeleteToken(accessToken []byte) error {
res, err := db.Exec("DELETE FROM accesstokens WHERE token LIKE ?", accessToken)
if err != nil {
return err
}
rowsAffected, _ := res.RowsAffected()
if rowsAffected == 0 {
return impart.HTTPError{http.StatusNotFound, "Token is invalid or doesn't exist"}
}
return nil
}
// FetchLastAccessToken creates a new non-expiring, valid access token for the given
// userID.
func (db *datastore) FetchLastAccessToken(userID int64) string {
var t []byte
err := db.QueryRow("SELECT token FROM accesstokens WHERE user_id = ? AND (expires IS NULL OR expires > "+db.now()+") ORDER BY created DESC LIMIT 1", userID).Scan(&t)
switch {
case err == sql.ErrNoRows:
return ""
case err != nil:
log.Error("Failed selecting from accesstoken: %v", err)
return ""
}
u, err := uuid.Parse(t)
if err != nil {
return ""
}
return u.String()
}
// GetAccessToken creates a new non-expiring, valid access token for the given
// userID.
func (db *datastore) GetAccessToken(userID int64) (string, error) {
return db.GetTemporaryOneTimeAccessToken(userID, 0, false)
}
// GetTemporaryAccessToken creates a new valid access token for the given
// userID that remains valid for the given time in seconds. If validSecs is 0,
// the access token doesn't automatically expire.
func (db *datastore) GetTemporaryAccessToken(userID int64, validSecs int) (string, error) {
return db.GetTemporaryOneTimeAccessToken(userID, validSecs, false)
}
// GetTemporaryOneTimeAccessToken creates a new valid access token for the given
// userID that remains valid for the given time in seconds and can only be used
// once if oneTime is true. If validSecs is 0, the access token doesn't
// automatically expire.
func (db *datastore) GetTemporaryOneTimeAccessToken(userID int64, validSecs int, oneTime bool) (string, error) {
u, err := uuid.NewV4()
if err != nil {
log.Error("Unable to generate token: %v", err)
return "", err
}
// Insert UUID to `accesstokens`
binTok := u[:]
expirationVal := "NULL"
if validSecs > 0 {
expirationVal = fmt.Sprintf("DATE_ADD("+db.now()+", INTERVAL %d SECOND)", validSecs)
}
_, err = db.Exec("INSERT INTO accesstokens (token, user_id, one_time, expires) VALUES (?, ?, ?, "+expirationVal+")", string(binTok), userID, oneTime)
if err != nil {
log.Error("Couldn't INSERT accesstoken: %v", err)
return "", err
}
return u.String(), nil
}
-func (db *datastore) CreateOwnedPost(post *SubmittedPost, accessToken, collAlias string) (*PublicPost, error) {
+func (db *datastore) CreateOwnedPost(post *SubmittedPost, accessToken, collAlias, hostName string) (*PublicPost, error) {
var userID, collID int64 = -1, -1
var coll *Collection
var err error
if accessToken != "" {
userID = db.GetUserID(accessToken)
if userID == -1 {
return nil, ErrBadAccessToken
}
if collAlias != "" {
coll, err = db.GetCollection(collAlias)
if err != nil {
return nil, err
}
+ coll.hostName = hostName
if coll.OwnerID != userID {
return nil, ErrForbiddenCollection
}
collID = coll.ID
}
}
rp := &PublicPost{}
rp.Post, err = db.CreatePost(userID, collID, post)
if err != nil {
return rp, err
}
if coll != nil {
coll.ForPublic()
rp.Collection = &CollectionObj{Collection: *coll}
}
return rp, nil
}
func (db *datastore) CreatePost(userID, collID int64, post *SubmittedPost) (*Post, error) {
idLen := postIDLen
friendlyID := store.GenerateFriendlyRandomString(idLen)
// Handle appearance / font face
appearance := post.Font
if !post.isFontValid() {
appearance = "norm"
}
var err error
ownerID := sql.NullInt64{
Valid: false,
}
ownerCollID := sql.NullInt64{
Valid: false,
}
slug := sql.NullString{"", false}
// If an alias was supplied, we'll add this to the collection as well.
if userID > 0 {
ownerID.Int64 = userID
ownerID.Valid = true
if collID > 0 {
ownerCollID.Int64 = collID
ownerCollID.Valid = true
var slugVal string
if post.Title != nil && *post.Title != "" {
slugVal = getSlug(*post.Title, post.Language.String)
if slugVal == "" {
slugVal = getSlug(*post.Content, post.Language.String)
}
} else {
slugVal = getSlug(*post.Content, post.Language.String)
}
if slugVal == "" {
slugVal = friendlyID
}
slug = sql.NullString{slugVal, true}
}
}
created := time.Now()
if db.driverName == driverSQLite {
// SQLite stores datetimes in UTC, so convert time.Now() to it here
created = created.UTC()
}
if post.Created != nil {
created, err = time.Parse("2006-01-02T15:04:05Z", *post.Created)
if err != nil {
log.Error("Unable to parse Created time '%s': %v", *post.Created, err)
created = time.Now()
if db.driverName == driverSQLite {
// SQLite stores datetimes in UTC, so convert time.Now() to it here
created = created.UTC()
}
}
}
stmt, err := db.Prepare("INSERT INTO posts (id, slug, title, content, text_appearance, language, rtl, privacy, owner_id, collection_id, created, updated, view_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, " + db.now() + ", ?)")
if err != nil {
return nil, err
}
defer stmt.Close()
_, err = stmt.Exec(friendlyID, slug, post.Title, post.Content, appearance, post.Language, post.IsRTL, 0, ownerID, ownerCollID, created, 0)
if err != nil {
if db.isDuplicateKeyErr(err) {
// Duplicate entry error; try a new slug
// TODO: make this a little more robust
slug = sql.NullString{id.GenSafeUniqueSlug(slug.String), true}
_, err = stmt.Exec(friendlyID, slug, post.Title, post.Content, appearance, post.Language, post.IsRTL, 0, ownerID, ownerCollID, created, 0)
if err != nil {
return nil, handleFailedPostInsert(fmt.Errorf("Retried slug generation, still failed: %v", err))
}
} else {
return nil, handleFailedPostInsert(err)
}
}
// TODO: return Created field in proper format
return &Post{
ID: friendlyID,
Slug: null.NewString(slug.String, slug.Valid),
Font: appearance,
Language: zero.NewString(post.Language.String, post.Language.Valid),
RTL: zero.NewBool(post.IsRTL.Bool, post.IsRTL.Valid),
OwnerID: null.NewInt(userID, true),
CollectionID: null.NewInt(userID, true),
Created: created.Truncate(time.Second).UTC(),
Updated: time.Now().Truncate(time.Second).UTC(),
Title: zero.NewString(*(post.Title), true),
Content: *(post.Content),
}, nil
}
// UpdateOwnedPost updates an existing post with only the given fields in the
// supplied AuthenticatedPost.
func (db *datastore) UpdateOwnedPost(post *AuthenticatedPost, userID int64) error {
params := []interface{}{}
var queryUpdates, sep, authCondition string
if post.Slug != nil && *post.Slug != "" {
queryUpdates += sep + "slug = ?"
sep = ", "
params = append(params, getSlug(*post.Slug, ""))
}
if post.Content != nil {
queryUpdates += sep + "content = ?"
sep = ", "
params = append(params, post.Content)
}
if post.Title != nil {
queryUpdates += sep + "title = ?"
sep = ", "
params = append(params, post.Title)
}
if post.Language.Valid {
queryUpdates += sep + "language = ?"
sep = ", "
params = append(params, post.Language.String)
}
if post.IsRTL.Valid {
queryUpdates += sep + "rtl = ?"
sep = ", "
params = append(params, post.IsRTL.Bool)
}
if post.Font != "" {
queryUpdates += sep + "text_appearance = ?"
sep = ", "
params = append(params, post.Font)
}
if post.Created != nil {
createTime, err := time.Parse(postMetaDateFormat, *post.Created)
if err != nil {
log.Error("Unable to parse Created date: %v", err)
return fmt.Errorf("That's the incorrect format for Created date.")
}
queryUpdates += sep + "created = ?"
sep = ", "
params = append(params, createTime)
}
// WHERE parameters...
// id = ?
params = append(params, post.ID)
// AND owner_id = ?
authCondition = "(owner_id = ?)"
params = append(params, userID)
if queryUpdates == "" {
return ErrPostNoUpdatableVals
}
queryUpdates += sep + "updated = " + db.now()
res, err := db.Exec("UPDATE posts SET "+queryUpdates+" WHERE id = ? AND "+authCondition, params...)
if err != nil {
log.Error("Unable to update owned post: %v", err)
return err
}
rowsAffected, _ := res.RowsAffected()
if rowsAffected == 0 {
// Show the correct error message if nothing was updated
var dummy int
err := db.QueryRow("SELECT 1 FROM posts WHERE id = ? AND "+authCondition, post.ID, params[len(params)-1]).Scan(&dummy)
switch {
case err == sql.ErrNoRows:
return ErrUnauthorizedEditPost
case err != nil:
log.Error("Failed selecting from posts: %v", err)
}
return nil
}
return nil
}
func (db *datastore) GetCollectionBy(condition string, value interface{}) (*Collection, error) {
c := &Collection{}
// FIXME: change Collection to reflect database values. Add helper functions to get actual values
var styleSheet, script, format zero.String
row := db.QueryRow("SELECT id, alias, title, description, style_sheet, script, format, owner_id, privacy, view_count FROM collections WHERE "+condition, value)
err := row.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &styleSheet, &script, &format, &c.OwnerID, &c.Visibility, &c.Views)
switch {
case err == sql.ErrNoRows:
return nil, impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."}
case err != nil:
log.Error("Failed selecting from collections: %v", err)
return nil, err
}
c.StyleSheet = styleSheet.String
c.Script = script.String
c.Format = format.String
c.Public = c.IsPublic()
c.db = db
return c, nil
}
func (db *datastore) GetCollection(alias string) (*Collection, error) {
return db.GetCollectionBy("alias = ?", alias)
}
func (db *datastore) GetCollectionForPad(alias string) (*Collection, error) {
c := &Collection{Alias: alias}
row := db.QueryRow("SELECT id, alias, title, description, privacy FROM collections WHERE alias = ?", alias)
err := row.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &c.Visibility)
switch {
case err == sql.ErrNoRows:
return c, impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."}
case err != nil:
log.Error("Failed selecting from collections: %v", err)
return c, ErrInternalGeneral
}
c.Public = c.IsPublic()
return c, nil
}
func (db *datastore) GetCollectionByID(id int64) (*Collection, error) {
return db.GetCollectionBy("id = ?", id)
}
func (db *datastore) GetCollectionFromDomain(host string) (*Collection, error) {
return db.GetCollectionBy("host = ?", host)
}
func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) error {
q := query.NewUpdate().
SetStringPtr(c.Title, "title").
SetStringPtr(c.Description, "description").
SetNullString(c.StyleSheet, "style_sheet").
SetNullString(c.Script, "script")
if c.Format != nil {
cf := &CollectionFormat{Format: c.Format.String}
if cf.Valid() {
q.SetNullString(c.Format, "format")
}
}
var updatePass bool
if c.Visibility != nil && (collVisibility(*c.Visibility)&CollProtected == 0 || c.Pass != "") {
q.SetIntPtr(c.Visibility, "privacy")
if c.Pass != "" {
updatePass = true
}
}
// WHERE values
q.Where("alias = ? AND owner_id = ?", alias, c.OwnerID)
if q.Updates == "" {
return ErrPostNoUpdatableVals
}
// Find any current domain
var collID int64
var rowsAffected int64
var changed bool
var res sql.Result
err := db.QueryRow("SELECT id FROM collections WHERE alias = ?", alias).Scan(&collID)
if err != nil {
log.Error("Failed selecting from collections: %v. Some things won't work.", err)
}
// Update MathJax value
if c.MathJax {
if db.driverName == driverSQLite {
_, err = db.Exec("INSERT OR REPLACE INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?)", collID, "render_mathjax", "1")
} else {
_, err = db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?) "+db.upsert("collection_id", "attribute")+" value = ?", collID, "render_mathjax", "1", "1")
}
if err != nil {
log.Error("Unable to insert render_mathjax value: %v", err)
return err
}
} else {
_, err = db.Exec("DELETE FROM collectionattributes WHERE collection_id = ? AND attribute = ?", collID, "render_mathjax")
if err != nil {
log.Error("Unable to delete render_mathjax value: %v", err)
return err
}
}
// Update rest of the collection data
res, err = db.Exec("UPDATE collections SET "+q.Updates+" WHERE "+q.Conditions, q.Params...)
if err != nil {
log.Error("Unable to update collection: %v", err)
return err
}
rowsAffected, _ = res.RowsAffected()
if !changed || rowsAffected == 0 {
// Show the correct error message if nothing was updated
var dummy int
err := db.QueryRow("SELECT 1 FROM collections WHERE alias = ? AND owner_id = ?", alias, c.OwnerID).Scan(&dummy)
switch {
case err == sql.ErrNoRows:
return ErrUnauthorizedEditPost
case err != nil:
log.Error("Failed selecting from collections: %v", err)
}
if !updatePass {
return nil
}
}
if updatePass {
hashedPass, err := auth.HashPass([]byte(c.Pass))
if err != nil {
log.Error("Unable to create hash: %s", err)
return impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
}
if db.driverName == driverSQLite {
_, err = db.Exec("INSERT OR REPLACE INTO collectionpasswords (collection_id, password) VALUES ((SELECT id FROM collections WHERE alias = ?), ?)", alias, hashedPass)
} else {
_, err = db.Exec("INSERT INTO collectionpasswords (collection_id, password) VALUES ((SELECT id FROM collections WHERE alias = ?), ?) "+db.upsert("collection_id")+" password = ?", alias, hashedPass, hashedPass)
}
if err != nil {
return err
}
}
return nil
}
const postCols = "id, slug, text_appearance, language, rtl, privacy, owner_id, collection_id, pinned_position, created, updated, view_count, title, content"
// getEditablePost returns a PublicPost with the given ID only if the given
// edit token is valid for the post.
func (db *datastore) GetEditablePost(id, editToken string) (*PublicPost, error) {
// FIXME: code duplicated from getPost()
// TODO: add slight logic difference to getPost / one func
var ownerName sql.NullString
p := &Post{}
row := db.QueryRow("SELECT "+postCols+", (SELECT username FROM users WHERE users.id = posts.owner_id) AS username FROM posts WHERE id = ? LIMIT 1", id)
err := row.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content, &ownerName)
switch {
case err == sql.ErrNoRows:
return nil, ErrPostNotFound
case err != nil:
log.Error("Failed selecting from collections: %v", err)
return nil, err
}
if p.Content == "" && p.Title.String == "" {
return nil, ErrPostUnpublished
}
res := p.processPost()
if ownerName.Valid {
res.Owner = &PublicUser{Username: ownerName.String}
}
return &res, nil
}
func (db *datastore) PostIDExists(id string) bool {
var dummy bool
err := db.QueryRow("SELECT 1 FROM posts WHERE id = ?", id).Scan(&dummy)
return err == nil && dummy
}
// GetPost gets a public-facing post object from the database. If collectionID
// is > 0, the post will be retrieved by slug and collection ID, rather than
// post ID.
// TODO: break this into two functions:
// - GetPost(id string)
// - GetCollectionPost(slug string, collectionID int64)
func (db *datastore) GetPost(id string, collectionID int64) (*PublicPost, error) {
var ownerName sql.NullString
p := &Post{}
var row *sql.Row
var where string
params := []interface{}{id}
if collectionID > 0 {
where = "slug = ? AND collection_id = ?"
params = append(params, collectionID)
} else {
where = "id = ?"
}
row = db.QueryRow("SELECT "+postCols+", (SELECT username FROM users WHERE users.id = posts.owner_id) AS username FROM posts WHERE "+where+" LIMIT 1", params...)
err := row.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content, &ownerName)
switch {
case err == sql.ErrNoRows:
if collectionID > 0 {
return nil, ErrCollectionPageNotFound
}
return nil, ErrPostNotFound
case err != nil:
log.Error("Failed selecting from collections: %v", err)
return nil, err
}
if p.Content == "" && p.Title.String == "" {
return nil, ErrPostUnpublished
}
res := p.processPost()
if ownerName.Valid {
res.Owner = &PublicUser{Username: ownerName.String}
}
return &res, nil
}
// TODO: don't duplicate getPost() functionality
func (db *datastore) GetOwnedPost(id string, ownerID int64) (*PublicPost, error) {
p := &Post{}
var row *sql.Row
where := "id = ? AND owner_id = ?"
params := []interface{}{id, ownerID}
row = db.QueryRow("SELECT "+postCols+" FROM posts WHERE "+where+" LIMIT 1", params...)
err := row.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content)
switch {
case err == sql.ErrNoRows:
return nil, ErrPostNotFound
case err != nil:
log.Error("Failed selecting from collections: %v", err)
return nil, err
}
if p.Content == "" && p.Title.String == "" {
return nil, ErrPostUnpublished
}
res := p.processPost()
return &res, nil
}
func (db *datastore) GetPostProperty(id string, collectionID int64, property string) (interface{}, error) {
propSelects := map[string]string{
"views": "view_count AS views",
}
selectQuery, ok := propSelects[property]
if !ok {
return nil, impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Invalid property: %s.", property)}
}
var res interface{}
var row *sql.Row
if collectionID != 0 {
row = db.QueryRow("SELECT "+selectQuery+" FROM posts WHERE slug = ? AND collection_id = ? LIMIT 1", id, collectionID)
} else {
row = db.QueryRow("SELECT "+selectQuery+" FROM posts WHERE id = ? LIMIT 1", id)
}
err := row.Scan(&res)
switch {
case err == sql.ErrNoRows:
return nil, impart.HTTPError{http.StatusNotFound, "Post not found."}
case err != nil:
log.Error("Failed selecting post: %v", err)
return nil, err
}
return res, nil
}
// GetPostsCount modifies the CollectionObj to include the correct number of
// standard (non-pinned) posts. It will return future posts if `includeFuture`
// is true.
func (db *datastore) GetPostsCount(c *CollectionObj, includeFuture bool) {
var count int64
timeCondition := ""
if !includeFuture {
timeCondition = "AND created <= " + db.now()
}
err := db.QueryRow("SELECT COUNT(*) FROM posts WHERE collection_id = ? AND pinned_position IS NULL "+timeCondition, c.ID).Scan(&count)
switch {
case err == sql.ErrNoRows:
c.TotalPosts = 0
case err != nil:
log.Error("Failed selecting from collections: %v", err)
c.TotalPosts = 0
}
c.TotalPosts = int(count)
}
// GetPosts retrieves all posts for the given Collection.
// It will return future posts if `includeFuture` is true.
// It will include only standard (non-pinned) posts unless `includePinned` is true.
// TODO: change includeFuture to isOwner, since that's how it's used
func (db *datastore) GetPosts(c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool) (*[]PublicPost, error) {
collID := c.ID
cf := c.NewFormat()
order := "DESC"
if cf.Ascending() && !forceRecentFirst {
order = "ASC"
}
pagePosts := cf.PostsPerPage()
start := page*pagePosts - pagePosts
if page == 0 {
start = 0
pagePosts = 1000
}
limitStr := ""
if page > 0 {
limitStr = fmt.Sprintf(" LIMIT %d, %d", start, pagePosts)
}
timeCondition := ""
if !includeFuture {
timeCondition = "AND created <= " + db.now()
}
pinnedCondition := ""
if !includePinned {
pinnedCondition = "AND pinned_position IS NULL"
}
rows, err := db.Query("SELECT "+postCols+" FROM posts WHERE collection_id = ? "+pinnedCondition+" "+timeCondition+" ORDER BY created "+order+limitStr, collID)
if err != nil {
log.Error("Failed selecting from posts: %v", err)
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts."}
}
defer rows.Close()
// TODO: extract this common row scanning logic for queries using `postCols`
posts := []PublicPost{}
for rows.Next() {
p := &Post{}
err = rows.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content)
if err != nil {
log.Error("Failed scanning row: %v", err)
break
}
p.extractData()
p.formatContent(c, includeFuture)
posts = append(posts, p.processPost())
}
err = rows.Err()
if err != nil {
log.Error("Error after Next() on rows: %v", err)
}
return &posts, nil
}
// GetPostsTagged retrieves all posts on the given Collection that contain the
// given tag.
// It will return future posts if `includeFuture` is true.
// TODO: change includeFuture to isOwner, since that's how it's used
func (db *datastore) GetPostsTagged(c *Collection, tag string, page int, includeFuture bool) (*[]PublicPost, error) {
collID := c.ID
cf := c.NewFormat()
order := "DESC"
if cf.Ascending() {
order = "ASC"
}
pagePosts := cf.PostsPerPage()
start := page*pagePosts - pagePosts
if page == 0 {
start = 0
pagePosts = 1000
}
limitStr := ""
if page > 0 {
limitStr = fmt.Sprintf(" LIMIT %d, %d", start, pagePosts)
}
timeCondition := ""
if !includeFuture {
timeCondition = "AND created <= " + db.now()
}
var rows *sql.Rows
var err error
if db.driverName == driverSQLite {
rows, err = db.Query("SELECT "+postCols+" FROM posts WHERE collection_id = ? AND LOWER(content) regexp ? "+timeCondition+" ORDER BY created "+order+limitStr, collID, `.*#`+strings.ToLower(tag)+`\b.*`)
} else {
rows, err = db.Query("SELECT "+postCols+" FROM posts WHERE collection_id = ? AND LOWER(content) RLIKE ? "+timeCondition+" ORDER BY created "+order+limitStr, collID, "#"+strings.ToLower(tag)+"[[:>:]]")
}
if err != nil {
log.Error("Failed selecting from posts: %v", err)
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts."}
}
defer rows.Close()
// TODO: extract this common row scanning logic for queries using `postCols`
posts := []PublicPost{}
for rows.Next() {
p := &Post{}
err = rows.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content)
if err != nil {
log.Error("Failed scanning row: %v", err)
break
}
p.extractData()
p.formatContent(c, includeFuture)
posts = append(posts, p.processPost())
}
err = rows.Err()
if err != nil {
log.Error("Error after Next() on rows: %v", err)
}
return &posts, nil
}
func (db *datastore) GetAPFollowers(c *Collection) (*[]RemoteUser, error) {
rows, err := db.Query("SELECT actor_id, inbox, shared_inbox FROM remotefollows f INNER JOIN remoteusers u ON f.remote_user_id = u.id WHERE collection_id = ?", c.ID)
if err != nil {
log.Error("Failed selecting from followers: %v", err)
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve followers."}
}
defer rows.Close()
followers := []RemoteUser{}
for rows.Next() {
f := RemoteUser{}
err = rows.Scan(&f.ActorID, &f.Inbox, &f.SharedInbox)
followers = append(followers, f)
}
return &followers, nil
}
// CanCollect returns whether or not the given user can add the given post to a
// collection. This is true when a post is already owned by the user.
// NOTE: this is currently only used to potentially add owned posts to a
// collection. This has the SIDE EFFECT of also generating a slug for the post.
// FIXME: make this side effect more explicit (or extract it)
func (db *datastore) CanCollect(cpr *ClaimPostRequest, userID int64) bool {
var title, content string
var lang sql.NullString
err := db.QueryRow("SELECT title, content, language FROM posts WHERE id = ? AND owner_id = ?", cpr.ID, userID).Scan(&title, &content, &lang)
switch {
case err == sql.ErrNoRows:
return false
case err != nil:
log.Error("Failed on post CanCollect(%s, %d): %v", cpr.ID, userID, err)
return false
}
// Since we have the post content and the post is collectable, generate the
// post's slug now.
cpr.Slug = getSlugFromPost(title, content, lang.String)
return true
}
func (db *datastore) AttemptClaim(p *ClaimPostRequest, query string, params []interface{}, slugIdx int) (sql.Result, error) {
qRes, err := db.Exec(query, params...)
if err != nil {
if db.isDuplicateKeyErr(err) && slugIdx > -1 {
s := id.GenSafeUniqueSlug(p.Slug)
if s == p.Slug {
// Sanity check to prevent infinite recursion
return qRes, fmt.Errorf("GenSafeUniqueSlug generated nothing unique: %s", s)
}
p.Slug = s
params[slugIdx] = p.Slug
return db.AttemptClaim(p, query, params, slugIdx)
}
return qRes, fmt.Errorf("attemptClaim: %s", err)
}
return qRes, nil
}
func (db *datastore) DispersePosts(userID int64, postIDs []string) (*[]ClaimPostResult, error) {
postClaimReqs := map[string]bool{}
res := []ClaimPostResult{}
for i := range postIDs {
postID := postIDs[i]
r := ClaimPostResult{Code: 0, ErrorMessage: ""}
// Perform post validation
if postID == "" {
r.ErrorMessage = "Missing post ID. "
}
if _, ok := postClaimReqs[postID]; ok {
r.Code = 429
r.ErrorMessage = "You've already tried anonymizing this post."
r.ID = postID
res = append(res, r)
continue
}
postClaimReqs[postID] = true
var err error
// Get full post information to return
var fullPost *PublicPost
fullPost, err = db.GetPost(postID, 0)
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
r.Code = err.Status
r.ErrorMessage = err.Message
r.ID = postID
res = append(res, r)
continue
} else {
log.Error("Error getting post in dispersePosts: %v", err)
}
}
if fullPost.OwnerID.Int64 != userID {
r.Code = http.StatusConflict
r.ErrorMessage = "Post is already owned by someone else."
r.ID = postID
res = append(res, r)
continue
}
var qRes sql.Result
var query string
var params []interface{}
// Do AND owner_id = ? for sanity.
// This should've been caught and returned with a good error message
// just above.
query = "UPDATE posts SET collection_id = NULL WHERE id = ? AND owner_id = ?"
params = []interface{}{postID, userID}
qRes, err = db.Exec(query, params...)
if err != nil {
r.Code = http.StatusInternalServerError
r.ErrorMessage = "A glitch happened on our end."
r.ID = postID
res = append(res, r)
log.Error("dispersePosts (post %s): %v", postID, err)
continue
}
// Post was successfully dispersed
r.Code = http.StatusOK
r.Post = fullPost
rowsAffected, _ := qRes.RowsAffected()
if rowsAffected == 0 {
// This was already claimed, but return 200
r.Code = http.StatusOK
}
res = append(res, r)
}
return &res, nil
}
-func (db *datastore) ClaimPosts(userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error) {
+func (db *datastore) ClaimPosts(cfg *config.Config, userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error) {
postClaimReqs := map[string]bool{}
res := []ClaimPostResult{}
postCollAlias := collAlias
for i := range *posts {
p := (*posts)[i]
if &p == nil {
continue
}
r := ClaimPostResult{Code: 0, ErrorMessage: ""}
// Perform post validation
if p.ID == "" {
r.ErrorMessage = "Missing post ID `id`. "
}
if _, ok := postClaimReqs[p.ID]; ok {
r.Code = 429
r.ErrorMessage = "You've already tried claiming this post."
r.ID = p.ID
res = append(res, r)
continue
}
postClaimReqs[p.ID] = true
canCollect := db.CanCollect(&p, userID)
if !canCollect && p.Token == "" {
// TODO: ensure post isn't owned by anyone else when a valid modify
// token is given.
r.ErrorMessage += "Missing post Edit Token `token`."
}
if r.ErrorMessage != "" {
// Post validate failed
r.Code = http.StatusBadRequest
r.ID = p.ID
res = append(res, r)
continue
}
var err error
var qRes sql.Result
var query string
var params []interface{}
var slugIdx int = -1
var coll *Collection
if collAlias == "" {
// Posts are being claimed at /posts/claim, not
// /collections/{alias}/collect, so use given individual collection
// to associate post with.
postCollAlias = p.CollectionAlias
}
if postCollAlias != "" {
// Associate this post with a collection
if p.CreateCollection {
// This is a new collection
// TODO: consider removing this. This seriously complicates this
// method and adds another (unnecessary?) logic path.
- coll, err = db.CreateCollection(postCollAlias, "", userID)
+ coll, err = db.CreateCollection(cfg, postCollAlias, "", userID)
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
r.Code = err.Status
r.ErrorMessage = err.Message
} else {
r.Code = http.StatusInternalServerError
r.ErrorMessage = "Unknown error occurred creating collection"
}
r.ID = p.ID
res = append(res, r)
continue
}
} else {
// Attempt to add to existing collection
coll, err = db.GetCollection(postCollAlias)
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
if err.Status == http.StatusNotFound {
// Show obfuscated "forbidden" response, as if attempting to add to an
// unowned blog.
r.Code = ErrForbiddenCollection.Status
r.ErrorMessage = ErrForbiddenCollection.Message
} else {
r.Code = err.Status
r.ErrorMessage = err.Message
}
} else {
r.Code = http.StatusInternalServerError
r.ErrorMessage = "Unknown error occurred claiming post with collection"
}
r.ID = p.ID
res = append(res, r)
continue
}
if coll.OwnerID != userID {
r.Code = ErrForbiddenCollection.Status
r.ErrorMessage = ErrForbiddenCollection.Message
r.ID = p.ID
res = append(res, r)
continue
}
}
if p.Slug == "" {
p.Slug = p.ID
}
if canCollect {
// User already owns this post, so just add it to the given
// collection.
query = "UPDATE posts SET collection_id = ?, slug = ? WHERE id = ? AND owner_id = ?"
params = []interface{}{coll.ID, p.Slug, p.ID, userID}
slugIdx = 1
} else {
query = "UPDATE posts SET owner_id = ?, collection_id = ?, slug = ? WHERE id = ? AND modify_token = ? AND owner_id IS NULL"
params = []interface{}{userID, coll.ID, p.Slug, p.ID, p.Token}
slugIdx = 2
}
} else {
query = "UPDATE posts SET owner_id = ? WHERE id = ? AND modify_token = ? AND owner_id IS NULL"
params = []interface{}{userID, p.ID, p.Token}
}
qRes, err = db.AttemptClaim(&p, query, params, slugIdx)
if err != nil {
r.Code = http.StatusInternalServerError
r.ErrorMessage = "An unknown error occurred."
r.ID = p.ID
res = append(res, r)
log.Error("claimPosts (post %s): %v", p.ID, err)
continue
}
// Get full post information to return
var fullPost *PublicPost
if p.Token != "" {
fullPost, err = db.GetEditablePost(p.ID, p.Token)
} else {
fullPost, err = db.GetPost(p.ID, 0)
}
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
r.Code = err.Status
r.ErrorMessage = err.Message
r.ID = p.ID
res = append(res, r)
continue
}
}
if fullPost.OwnerID.Int64 != userID {
r.Code = http.StatusConflict
r.ErrorMessage = "Post is already owned by someone else."
r.ID = p.ID
res = append(res, r)
continue
}
// Post was successfully claimed
r.Code = http.StatusOK
r.Post = fullPost
if coll != nil {
r.Post.Collection = &CollectionObj{Collection: *coll}
}
rowsAffected, _ := qRes.RowsAffected()
if rowsAffected == 0 {
// This was already claimed, but return 200
r.Code = http.StatusOK
}
res = append(res, r)
}
return &res, nil
}
func (db *datastore) UpdatePostPinState(pinned bool, postID string, collID, ownerID, pos int64) error {
if pos <= 0 || pos > 20 {
pos = db.GetLastPinnedPostPos(collID) + 1
if pos == -1 {
pos = 1
}
}
var err error
if pinned {
_, err = db.Exec("UPDATE posts SET pinned_position = ? WHERE id = ?", pos, postID)
} else {
_, err = db.Exec("UPDATE posts SET pinned_position = NULL WHERE id = ?", postID)
}
if err != nil {
log.Error("Unable to update pinned post: %v", err)
return err
}
return nil
}
func (db *datastore) GetLastPinnedPostPos(collID int64) int64 {
var lastPos sql.NullInt64
err := db.QueryRow("SELECT MAX(pinned_position) FROM posts WHERE collection_id = ? AND pinned_position IS NOT NULL", collID).Scan(&lastPos)
switch {
case err == sql.ErrNoRows:
return -1
case err != nil:
log.Error("Failed selecting from posts: %v", err)
return -1
}
if !lastPos.Valid {
return -1
}
return lastPos.Int64
}
func (db *datastore) GetPinnedPosts(coll *CollectionObj) (*[]PublicPost, error) {
// FIXME: sqlite-backed instances don't include ellipsis on truncated titles
rows, err := db.Query("SELECT id, slug, title, "+db.clip("content", 80)+", pinned_position FROM posts WHERE collection_id = ? AND pinned_position IS NOT NULL ORDER BY pinned_position ASC", coll.ID)
if err != nil {
log.Error("Failed selecting pinned posts: %v", err)
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve pinned posts."}
}
defer rows.Close()
posts := []PublicPost{}
for rows.Next() {
p := &Post{}
err = rows.Scan(&p.ID, &p.Slug, &p.Title, &p.Content, &p.PinnedPosition)
if err != nil {
log.Error("Failed scanning row: %v", err)
break
}
p.extractData()
pp := p.processPost()
pp.Collection = coll
posts = append(posts, pp)
}
return &posts, nil
}
func (db *datastore) GetCollections(u *User) (*[]Collection, error) {
rows, err := db.Query("SELECT id, alias, title, description, privacy, view_count FROM collections WHERE owner_id = ? ORDER BY id ASC", u.ID)
if err != nil {
log.Error("Failed selecting from collections: %v", err)
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user collections."}
}
defer rows.Close()
colls := []Collection{}
for rows.Next() {
c := Collection{}
err = rows.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &c.Visibility, &c.Views)
if err != nil {
log.Error("Failed scanning row: %v", err)
break
}
c.URL = c.CanonicalURL()
c.Public = c.IsPublic()
colls = append(colls, c)
}
err = rows.Err()
if err != nil {
log.Error("Error after Next() on rows: %v", err)
}
return &colls, nil
}
func (db *datastore) GetPublishableCollections(u *User) (*[]Collection, error) {
c, err := db.GetCollections(u)
if err != nil {
return nil, err
}
if len(*c) == 0 {
return nil, impart.HTTPError{http.StatusInternalServerError, "You don't seem to have any blogs; they might've moved to another account. Try logging out and logging into your other account."}
}
return c, nil
}
func (db *datastore) GetMeStats(u *User) userMeStats {
s := userMeStats{}
// User counts
colls, _ := db.GetUserCollectionCount(u.ID)
s.TotalCollections = colls
var articles, collPosts uint64
err := db.QueryRow("SELECT COUNT(*) FROM posts WHERE owner_id = ? AND collection_id IS NULL", u.ID).Scan(&articles)
if err != nil && err != sql.ErrNoRows {
log.Error("Couldn't get articles count for user %d: %v", u.ID, err)
}
s.TotalArticles = articles
err = db.QueryRow("SELECT COUNT(*) FROM posts WHERE owner_id = ? AND collection_id IS NOT NULL", u.ID).Scan(&collPosts)
if err != nil && err != sql.ErrNoRows {
log.Error("Couldn't get coll posts count for user %d: %v", u.ID, err)
}
s.CollectionPosts = collPosts
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 := ""
if alias != "" {
where = " AND alias = ?"
params = append(params, alias)
}
rows, err := db.Query("SELECT p.id, p.slug, p.view_count, p.title, c.alias, c.title, c.description, c.view_count FROM posts p LEFT JOIN collections c ON p.collection_id = c.id WHERE p.owner_id = ?"+where+" ORDER BY p.view_count DESC, created DESC LIMIT 25", params...)
if err != nil {
log.Error("Failed selecting from posts: %v", err)
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user top posts."}
}
defer rows.Close()
posts := []PublicPost{}
var gotErr bool
for rows.Next() {
p := Post{}
c := Collection{}
var alias, title, description sql.NullString
var views sql.NullInt64
err = rows.Scan(&p.ID, &p.Slug, &p.ViewCount, &p.Title, &alias, &title, &description, &views)
if err != nil {
log.Error("Failed scanning User.getPosts() row: %v", err)
gotErr = true
break
}
p.extractData()
pubPost := p.processPost()
if alias.Valid && alias.String != "" {
c.Alias = alias.String
c.Title = title.String
c.Description = description.String
c.Views = views.Int64
pubPost.Collection = &CollectionObj{Collection: c}
}
posts = append(posts, pubPost)
}
err = rows.Err()
if err != nil {
log.Error("Error after Next() on rows: %v", err)
}
if gotErr && len(posts) == 0 {
// There were a lot of errors
return nil, impart.HTTPError{http.StatusInternalServerError, "Unable to get data."}
}
return &posts, nil
}
func (db *datastore) GetAnonymousPosts(u *User) (*[]PublicPost, error) {
rows, err := db.Query("SELECT id, view_count, title, created, updated, content FROM posts WHERE owner_id = ? AND collection_id IS NULL ORDER BY created DESC", u.ID)
if err != nil {
log.Error("Failed selecting from posts: %v", err)
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user anonymous posts."}
}
defer rows.Close()
posts := []PublicPost{}
for rows.Next() {
p := Post{}
err = rows.Scan(&p.ID, &p.ViewCount, &p.Title, &p.Created, &p.Updated, &p.Content)
if err != nil {
log.Error("Failed scanning row: %v", err)
break
}
p.extractData()
posts = append(posts, p.processPost())
}
err = rows.Err()
if err != nil {
log.Error("Error after Next() on rows: %v", err)
}
return &posts, nil
}
func (db *datastore) GetUserPosts(u *User) (*[]PublicPost, error) {
rows, err := db.Query("SELECT p.id, p.slug, p.view_count, p.title, p.created, p.updated, p.content, p.text_appearance, p.language, p.rtl, c.alias, c.title, c.description, c.view_count FROM posts p LEFT JOIN collections c ON collection_id = c.id WHERE p.owner_id = ? ORDER BY created ASC", u.ID)
if err != nil {
log.Error("Failed selecting from posts: %v", err)
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user posts."}
}
defer rows.Close()
posts := []PublicPost{}
var gotErr bool
for rows.Next() {
p := Post{}
c := Collection{}
var alias, title, description sql.NullString
var views sql.NullInt64
err = rows.Scan(&p.ID, &p.Slug, &p.ViewCount, &p.Title, &p.Created, &p.Updated, &p.Content, &p.Font, &p.Language, &p.RTL, &alias, &title, &description, &views)
if err != nil {
log.Error("Failed scanning User.getPosts() row: %v", err)
gotErr = true
break
}
p.extractData()
pubPost := p.processPost()
if alias.Valid && alias.String != "" {
c.Alias = alias.String
c.Title = title.String
c.Description = description.String
c.Views = views.Int64
pubPost.Collection = &CollectionObj{Collection: c}
}
posts = append(posts, pubPost)
}
err = rows.Err()
if err != nil {
log.Error("Error after Next() on rows: %v", err)
}
if gotErr && len(posts) == 0 {
// There were a lot of errors
return nil, impart.HTTPError{http.StatusInternalServerError, "Unable to get data."}
}
return &posts, nil
}
func (db *datastore) GetUserPostsCount(userID int64) int64 {
var count int64
err := db.QueryRow("SELECT COUNT(*) FROM posts WHERE owner_id = ?", userID).Scan(&count)
switch {
case err == sql.ErrNoRows:
return 0
case err != nil:
log.Error("Failed selecting posts count for user %d: %v", userID, err)
return 0
}
return count
}
// ChangeSettings takes a User and applies the changes in the given
// userSettings, MODIFYING THE USER with successful changes.
func (db *datastore) ChangeSettings(app *App, u *User, s *userSettings) error {
var errPass error
q := query.NewUpdate()
// Update email if given
if s.Email != "" {
encEmail, err := data.Encrypt(app.keys.EmailKey, s.Email)
if err != nil {
log.Error("Couldn't encrypt email %s: %s\n", s.Email, err)
return impart.HTTPError{http.StatusInternalServerError, "Unable to encrypt email address."}
}
q.SetBytes(encEmail, "email")
// Update the email if something goes awry updating the password
defer func() {
if errPass != nil {
db.UpdateEncryptedUserEmail(u.ID, encEmail)
}
}()
u.Email = zero.StringFrom(s.Email)
}
// Update username if given
var newUsername string
if s.Username != "" {
var ie *impart.HTTPError
newUsername, ie = getValidUsername(app, s.Username, u.Username)
if ie != nil {
// Username is invalid
return *ie
}
if !author.IsValidUsername(app.cfg, newUsername) {
// Ensure the username is syntactically correct.
return impart.HTTPError{http.StatusPreconditionFailed, "Username isn't valid."}
}
t, err := db.Begin()
if err != nil {
log.Error("Couldn't start username change transaction: %v", err)
return err
}
_, err = t.Exec("UPDATE users SET username = ? WHERE id = ?", newUsername, u.ID)
if err != nil {
t.Rollback()
if db.isDuplicateKeyErr(err) {
return impart.HTTPError{http.StatusConflict, "Username is already taken."}
}
log.Error("Unable to update users table: %v", err)
return ErrInternalGeneral
}
_, err = t.Exec("UPDATE collections SET alias = ? WHERE alias = ? AND owner_id = ?", newUsername, u.Username, u.ID)
if err != nil {
t.Rollback()
if db.isDuplicateKeyErr(err) {
return impart.HTTPError{http.StatusConflict, "Username is already taken."}
}
log.Error("Unable to update collection: %v", err)
return ErrInternalGeneral
}
// Keep track of name changes for redirection
db.RemoveCollectionRedirect(t, newUsername)
_, err = t.Exec("UPDATE collectionredirects SET new_alias = ? WHERE new_alias = ?", newUsername, u.Username)
if err != nil {
log.Error("Unable to update collectionredirects: %v", err)
}
_, err = t.Exec("INSERT INTO collectionredirects (prev_alias, new_alias) VALUES (?, ?)", u.Username, newUsername)
if err != nil {
log.Error("Unable to add new collectionredirect: %v", err)
}
err = t.Commit()
if err != nil {
t.Rollback()
log.Error("Rolling back after Commit(): %v\n", err)
return err
}
u.Username = newUsername
}
// Update passphrase if given
if s.NewPass != "" {
// Check if user has already set a password
var err error
u.HasPass, err = db.IsUserPassSet(u.ID)
if err != nil {
errPass = impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data."}
return errPass
}
if u.HasPass {
// Check if currently-set password is correct
hashedPass := u.HashedPass
if len(hashedPass) == 0 {
authUser, err := db.GetUserForAuthByID(u.ID)
if err != nil {
errPass = err
return errPass
}
hashedPass = authUser.HashedPass
}
if !auth.Authenticated(hashedPass, []byte(s.OldPass)) {
errPass = impart.HTTPError{http.StatusUnauthorized, "Incorrect password."}
return errPass
}
}
hashedPass, err := auth.HashPass([]byte(s.NewPass))
if err != nil {
errPass = impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
return errPass
}
q.SetBytes(hashedPass, "password")
}
// WHERE values
q.Append(u.ID)
if q.Updates == "" {
if s.Username == "" {
return ErrPostNoUpdatableVals
}
// Nothing to update except username. That was successful, so return now.
return nil
}
res, err := db.Exec("UPDATE users SET "+q.Updates+" WHERE id = ?", q.Params...)
if err != nil {
log.Error("Unable to update collection: %v", err)
return err
}
rowsAffected, _ := res.RowsAffected()
if rowsAffected == 0 {
// Show the correct error message if nothing was updated
var dummy int
err := db.QueryRow("SELECT 1 FROM users WHERE id = ?", u.ID).Scan(&dummy)
switch {
case err == sql.ErrNoRows:
return ErrUnauthorizedGeneral
case err != nil:
log.Error("Failed selecting from users: %v", err)
}
return nil
}
if s.NewPass != "" && !u.HasPass {
u.HasPass = true
}
return nil
}
func (db *datastore) ChangePassphrase(userID int64, sudo bool, curPass string, hashedPass []byte) error {
var dbPass []byte
err := db.QueryRow("SELECT password FROM users WHERE id = ?", userID).Scan(&dbPass)
switch {
case err == sql.ErrNoRows:
return ErrUserNotFound
case err != nil:
log.Error("Couldn't SELECT user password for change: %v", err)
return err
}
if !sudo && !auth.Authenticated(dbPass, []byte(curPass)) {
return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."}
}
_, err = db.Exec("UPDATE users SET password = ? WHERE id = ?", hashedPass, userID)
if err != nil {
log.Error("Could not update passphrase: %v", err)
return err
}
return nil
}
func (db *datastore) RemoveCollectionRedirect(t *sql.Tx, alias string) error {
_, err := t.Exec("DELETE FROM collectionredirects WHERE prev_alias = ?", alias)
if err != nil {
log.Error("Unable to delete from collectionredirects: %v", err)
return err
}
return nil
}
func (db *datastore) GetCollectionRedirect(alias string) (new string) {
row := db.QueryRow("SELECT new_alias FROM collectionredirects WHERE prev_alias = ?", alias)
err := row.Scan(&new)
if err != nil && err != sql.ErrNoRows {
log.Error("Failed selecting from collectionredirects: %v", err)
}
return
}
func (db *datastore) DeleteCollection(alias string, userID int64) error {
c := &Collection{Alias: alias}
var username string
row := db.QueryRow("SELECT username FROM users WHERE id = ?", userID)
err := row.Scan(&username)
if err != nil {
return err
}
// Ensure user isn't deleting their main blog
if alias == username {
return impart.HTTPError{http.StatusForbidden, "You cannot currently delete your primary blog."}
}
row = db.QueryRow("SELECT id FROM collections WHERE alias = ? AND owner_id = ?", alias, userID)
err = row.Scan(&c.ID)
switch {
case err == sql.ErrNoRows:
return impart.HTTPError{http.StatusNotFound, "Collection doesn't exist or you're not allowed to delete it."}
case err != nil:
log.Error("Failed selecting from collections: %v", err)
return ErrInternalGeneral
}
t, err := db.Begin()
if err != nil {
return err
}
// Float all collection's posts
_, err = t.Exec("UPDATE posts SET collection_id = NULL WHERE collection_id = ? AND owner_id = ?", c.ID, userID)
if err != nil {
t.Rollback()
return err
}
// Remove redirects to or from this collection
_, err = t.Exec("DELETE FROM collectionredirects WHERE prev_alias = ? OR new_alias = ?", alias, alias)
if err != nil {
t.Rollback()
return err
}
// Remove any optional collection password
_, err = t.Exec("DELETE FROM collectionpasswords WHERE collection_id = ?", c.ID)
if err != nil {
t.Rollback()
return err
}
// Finally, delete collection itself
_, err = t.Exec("DELETE FROM collections WHERE id = ?", c.ID)
if err != nil {
t.Rollback()
return err
}
err = t.Commit()
if err != nil {
t.Rollback()
return err
}
return nil
}
func (db *datastore) IsCollectionAttributeOn(id int64, attr string) bool {
var v string
err := db.QueryRow("SELECT value FROM collectionattributes WHERE collection_id = ? AND attribute = ?", id, attr).Scan(&v)
switch {
case err == sql.ErrNoRows:
return false
case err != nil:
log.Error("Couldn't SELECT value in isCollectionAttributeOn for attribute '%s': %v", attr, err)
return false
}
return v == "1"
}
func (db *datastore) CollectionHasAttribute(id int64, attr string) bool {
var dummy string
err := db.QueryRow("SELECT value FROM collectionattributes WHERE collection_id = ? AND attribute = ?", id, attr).Scan(&dummy)
switch {
case err == sql.ErrNoRows:
return false
case err != nil:
log.Error("Couldn't SELECT value in collectionHasAttribute for attribute '%s': %v", attr, err)
return false
}
return true
}
func (db *datastore) DeleteAccount(userID int64) (l *string, err error) {
debug := ""
l = &debug
t, err := db.Begin()
if err != nil {
stringLogln(l, "Unable to begin: %v", err)
return
}
// Get all collections
rows, err := db.Query("SELECT id, alias FROM collections WHERE owner_id = ?", userID)
if err != nil {
t.Rollback()
stringLogln(l, "Unable to get collections: %v", err)
return
}
defer rows.Close()
colls := []Collection{}
var c Collection
for rows.Next() {
err = rows.Scan(&c.ID, &c.Alias)
if err != nil {
t.Rollback()
stringLogln(l, "Unable to scan collection cols: %v", err)
return
}
colls = append(colls, c)
}
var res sql.Result
for _, c := range colls {
// TODO: user deleteCollection() func
// Delete tokens
res, err = t.Exec("DELETE FROM collectionattributes WHERE collection_id = ?", c.ID)
if err != nil {
t.Rollback()
stringLogln(l, "Unable to delete attributes on %s: %v", c.Alias, err)
return
}
rs, _ := res.RowsAffected()
stringLogln(l, "Deleted %d for %s from collectionattributes", rs, c.Alias)
// Remove any optional collection password
res, err = t.Exec("DELETE FROM collectionpasswords WHERE collection_id = ?", c.ID)
if err != nil {
t.Rollback()
stringLogln(l, "Unable to delete passwords on %s: %v", c.Alias, err)
return
}
rs, _ = res.RowsAffected()
stringLogln(l, "Deleted %d for %s from collectionpasswords", rs, c.Alias)
// Remove redirects to this collection
res, err = t.Exec("DELETE FROM collectionredirects WHERE new_alias = ?", c.Alias)
if err != nil {
t.Rollback()
stringLogln(l, "Unable to delete redirects on %s: %v", c.Alias, err)
return
}
rs, _ = res.RowsAffected()
stringLogln(l, "Deleted %d for %s from collectionredirects", rs, c.Alias)
}
// Delete collections
res, err = t.Exec("DELETE FROM collections WHERE owner_id = ?", userID)
if err != nil {
t.Rollback()
stringLogln(l, "Unable to delete collections: %v", err)
return
}
rs, _ := res.RowsAffected()
stringLogln(l, "Deleted %d from collections", rs)
// Delete tokens
res, err = t.Exec("DELETE FROM accesstokens WHERE user_id = ?", userID)
if err != nil {
t.Rollback()
stringLogln(l, "Unable to delete access tokens: %v", err)
return
}
rs, _ = res.RowsAffected()
stringLogln(l, "Deleted %d from accesstokens", rs)
// Delete posts
res, err = t.Exec("DELETE FROM posts WHERE owner_id = ?", userID)
if err != nil {
t.Rollback()
stringLogln(l, "Unable to delete posts: %v", err)
return
}
rs, _ = res.RowsAffected()
stringLogln(l, "Deleted %d from posts", rs)
res, err = t.Exec("DELETE FROM userattributes WHERE user_id = ?", userID)
if err != nil {
t.Rollback()
stringLogln(l, "Unable to delete attributes: %v", err)
return
}
rs, _ = res.RowsAffected()
stringLogln(l, "Deleted %d from userattributes", rs)
res, err = t.Exec("DELETE FROM users WHERE id = ?", userID)
if err != nil {
t.Rollback()
stringLogln(l, "Unable to delete user: %v", err)
return
}
rs, _ = res.RowsAffected()
stringLogln(l, "Deleted %d from users", rs)
err = t.Commit()
if err != nil {
t.Rollback()
stringLogln(l, "Unable to commit: %v", err)
return
}
return
}
func (db *datastore) GetAPActorKeys(collectionID int64) ([]byte, []byte) {
var pub, priv []byte
err := db.QueryRow("SELECT public_key, private_key FROM collectionkeys WHERE collection_id = ?", collectionID).Scan(&pub, &priv)
switch {
case err == sql.ErrNoRows:
// Generate keys
pub, priv = activitypub.GenerateKeys()
_, err = db.Exec("INSERT INTO collectionkeys (collection_id, public_key, private_key) VALUES (?, ?, ?)", collectionID, pub, priv)
if err != nil {
log.Error("Unable to INSERT new activitypub keypair: %v", err)
return nil, nil
}
case err != nil:
log.Error("Couldn't SELECT collectionkeys: %v", err)
return nil, nil
}
return pub, priv
}
func (db *datastore) CreateUserInvite(id string, userID int64, maxUses int, expires *time.Time) error {
_, err := db.Exec("INSERT INTO userinvites (id, owner_id, max_uses, created, expires, inactive) VALUES (?, ?, ?, "+db.now()+", ?, 0)", id, userID, maxUses, expires)
return err
}
func (db *datastore) GetUserInvites(userID int64) (*[]Invite, error) {
rows, err := db.Query("SELECT id, max_uses, created, expires, inactive FROM userinvites WHERE owner_id = ? ORDER BY created DESC", userID)
if err != nil {
log.Error("Failed selecting from userinvites: %v", err)
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user invites."}
}
defer rows.Close()
is := []Invite{}
for rows.Next() {
i := Invite{}
err = rows.Scan(&i.ID, &i.MaxUses, &i.Created, &i.Expires, &i.Inactive)
is = append(is, i)
}
return &is, nil
}
func (db *datastore) GetUserInvite(id string) (*Invite, error) {
var i Invite
err := db.QueryRow("SELECT id, max_uses, created, expires, inactive FROM userinvites WHERE id = ?", id).Scan(&i.ID, &i.MaxUses, &i.Created, &i.Expires, &i.Inactive)
switch {
case err == sql.ErrNoRows:
return nil, impart.HTTPError{http.StatusNotFound, "Invite doesn't exist."}
case err != nil:
log.Error("Failed selecting invite: %v", err)
return nil, err
}
return &i, nil
}
func (db *datastore) GetUsersInvitedCount(id string) int64 {
var count int64
err := db.QueryRow("SELECT COUNT(*) FROM usersinvited WHERE invite_id = ?", id).Scan(&count)
switch {
case err == sql.ErrNoRows:
return 0
case err != nil:
log.Error("Failed selecting users invited count: %v", err)
return 0
}
return count
}
func (db *datastore) CreateInvitedUser(inviteID string, userID int64) error {
_, err := db.Exec("INSERT INTO usersinvited (invite_id, user_id) VALUES (?, ?)", inviteID, userID)
return err
}
func (db *datastore) GetInstancePages() ([]*instanceContent, error) {
return db.GetAllDynamicContent("page")
}
func (db *datastore) GetAllDynamicContent(t string) ([]*instanceContent, error) {
where := ""
params := []interface{}{}
if t != "" {
where = " WHERE content_type = ?"
params = append(params, t)
}
rows, err := db.Query("SELECT id, title, content, updated, content_type FROM appcontent"+where, params...)
if err != nil {
log.Error("Failed selecting from appcontent: %v", err)
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve instance pages."}
}
defer rows.Close()
pages := []*instanceContent{}
for rows.Next() {
c := &instanceContent{}
err = rows.Scan(&c.ID, &c.Title, &c.Content, &c.Updated, &c.Type)
if err != nil {
log.Error("Failed scanning row: %v", err)
break
}
pages = append(pages, c)
}
err = rows.Err()
if err != nil {
log.Error("Error after Next() on rows: %v", err)
}
return pages, nil
}
func (db *datastore) GetDynamicContent(id string) (*instanceContent, error) {
c := &instanceContent{
ID: id,
}
err := db.QueryRow("SELECT title, content, updated, content_type FROM appcontent WHERE id = ?", id).Scan(&c.Title, &c.Content, &c.Updated, &c.Type)
switch {
case err == sql.ErrNoRows:
return nil, nil
case err != nil:
log.Error("Couldn't SELECT FROM appcontent for id '%s': %v", id, err)
return nil, err
}
return c, nil
}
func (db *datastore) UpdateDynamicContent(id, title, content, contentType string) error {
var err error
if db.driverName == driverSQLite {
_, err = db.Exec("INSERT OR REPLACE INTO appcontent (id, title, content, updated, content_type) VALUES (?, ?, ?, "+db.now()+", ?)", id, title, content, contentType)
} else {
_, err = db.Exec("INSERT INTO appcontent (id, title, content, updated, content_type) VALUES (?, ?, ?, "+db.now()+", ?) "+db.upsert("id")+" title = ?, content = ?, updated = "+db.now(), id, title, content, contentType, title, content)
}
if err != nil {
log.Error("Unable to INSERT appcontent for '%s': %v", id, err)
}
return err
}
func (db *datastore) GetAllUsers(page uint) (*[]User, error) {
limitStr := fmt.Sprintf("0, %d", adminUsersPerPage)
if page > 1 {
limitStr = fmt.Sprintf("%d, %d", (page-1)*adminUsersPerPage, adminUsersPerPage)
}
rows, err := db.Query("SELECT id, username, created FROM users ORDER BY created DESC LIMIT " + limitStr)
if err != nil {
log.Error("Failed selecting from posts: %v", err)
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user posts."}
}
defer rows.Close()
users := []User{}
for rows.Next() {
u := User{}
err = rows.Scan(&u.ID, &u.Username, &u.Created)
if err != nil {
log.Error("Failed scanning GetAllUsers() row: %v", err)
break
}
users = append(users, u)
}
return &users, nil
}
func (db *datastore) GetAllUsersCount() int64 {
var count int64
err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
switch {
case err == sql.ErrNoRows:
return 0
case err != nil:
log.Error("Failed selecting all users count: %v", err)
return 0
}
return count
}
func (db *datastore) GetUserLastPostTime(id int64) (*time.Time, error) {
var t time.Time
err := db.QueryRow("SELECT created FROM posts WHERE owner_id = ? ORDER BY created DESC LIMIT 1", id).Scan(&t)
switch {
case err == sql.ErrNoRows:
return nil, nil
case err != nil:
log.Error("Failed selecting last post time from posts: %v", err)
return nil, err
}
return &t, nil
}
func (db *datastore) GetCollectionLastPostTime(id int64) (*time.Time, error) {
var t time.Time
err := db.QueryRow("SELECT created FROM posts WHERE collection_id = ? ORDER BY created DESC LIMIT 1", id).Scan(&t)
switch {
case err == sql.ErrNoRows:
return nil, nil
case err != nil:
log.Error("Failed selecting last post time from posts: %v", err)
return nil, err
}
return &t, nil
}
// DatabaseInitialized returns whether or not the current datastore has been
// initialized with the correct schema.
// Currently, it checks to see if the `users` table exists.
func (db *datastore) DatabaseInitialized() bool {
var dummy string
var err error
if db.driverName == driverSQLite {
err = db.QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'users'").Scan(&dummy)
} else {
err = db.QueryRow("SHOW TABLES LIKE 'users'").Scan(&dummy)
}
switch {
case err == sql.ErrNoRows:
return false
case err != nil:
log.Error("Couldn't SHOW TABLES: %v", err)
return false
}
return true
}
func stringLogln(log *string, s string, v ...interface{}) {
*log += fmt.Sprintf(s+"\n", v...)
}
func handleFailedPostInsert(err error) error {
log.Error("Couldn't insert into posts: %v", err)
return err
}
diff --git a/go.mod b/go.mod
index 9bce8c4..cc5fc57 100644
--- a/go.mod
+++ b/go.mod
@@ -1,81 +1,80 @@
module github.com/writeas/writefreely
require (
github.com/BurntSushi/toml v0.3.1 // indirect
github.com/Unknwon/com v0.0.0-20181010210213-41959bdd855f // indirect
github.com/Unknwon/i18n v0.0.0-20171114194641-b64d33658966 // indirect
github.com/alecthomas/gometalinter v3.0.0+incompatible // indirect
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect
- github.com/beevik/etree v1.1.0 // indirect
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737 // indirect
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49 // indirect
github.com/clbanning/mxj v1.8.4 // indirect
github.com/dustin/go-humanize v1.0.0
github.com/fatih/color v1.7.0
- github.com/fatih/structs v1.1.0 // indirect
github.com/go-macaron/cache v0.0.0-20151013081102-561735312776 // indirect
github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191 // indirect
github.com/go-macaron/session v0.0.0-20190131233854-0a0a789bf193 // indirect
github.com/go-sql-driver/mysql v1.4.1
github.com/go-test/deep v1.0.1 // indirect
github.com/gogits/gogs v0.11.86
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561 // indirect
github.com/gogs/go-libravatar v0.0.0-20161120025154-cd1abbd55d09 // indirect
github.com/gogs/gogs v0.11.86 // indirect
github.com/gogs/minwinsvc v0.0.0-20170301035411-95be6356811a // indirect
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
github.com/gorilla/feeds v1.1.0
github.com/gorilla/mux v1.7.0
github.com/gorilla/schema v1.0.2
github.com/gorilla/sessions v1.1.3
github.com/guregu/null v3.4.0+incompatible
github.com/ikeikeikeike/go-sitemap-generator v1.0.1
+ github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2
github.com/imdario/mergo v0.3.7 // indirect
github.com/jteeuwen/go-bindata v3.0.7+incompatible // indirect
github.com/jtolds/gls v4.2.1+incompatible // indirect
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec
github.com/lunixbochs/vtclean v1.0.0 // indirect
github.com/manifoldco/promptui v0.3.2
github.com/mattn/go-colorable v0.1.0 // indirect
github.com/mattn/go-sqlite3 v1.10.0
github.com/mcuadros/go-version v0.0.0-20180611085657-6d5863ca60fa // indirect
github.com/microcosm-cc/bluemonday v1.0.2
github.com/mitchellh/go-wordwrap v1.0.0
github.com/nicksnyder/go-i18n v1.10.0 // indirect
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d
github.com/pelletier/go-toml v1.2.0 // indirect
github.com/pkg/errors v0.8.1 // indirect
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect
github.com/stretchr/testify v1.3.0 // indirect
github.com/writeas/activity v0.1.2
github.com/writeas/go-strip-markdown v2.0.1+incompatible
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2
github.com/writeas/httpsig v1.0.0
github.com/writeas/impart v1.1.0
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219
github.com/writeas/nerds v1.0.0
github.com/writeas/openssl-go v1.0.0 // indirect
github.com/writeas/saturday v1.7.1
github.com/writeas/slug v1.2.0
github.com/writeas/web-core v1.0.0
github.com/writefreely/go-nodeinfo v1.2.0
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f // indirect
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect
golang.org/x/net v0.0.0-20190206173232-65e2d4e15006 // indirect
golang.org/x/sys v0.0.0-20190209173611-3b5209105503 // indirect
golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 // indirect
google.golang.org/appengine v1.4.0 // indirect
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c // indirect
gopkg.in/bufio.v1 v1.0.0-20140618132640-567b2bfa514e // indirect
gopkg.in/clog.v1 v1.2.0 // indirect
gopkg.in/ini.v1 v1.41.0
gopkg.in/macaron.v1 v1.3.2 // indirect
gopkg.in/redis.v2 v2.3.2 // indirect
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 // indirect
gopkg.in/yaml.v2 v2.2.2 // indirect
)
diff --git a/go.sum b/go.sum
index ae7333a..8898bec 100644
--- a/go.sum
+++ b/go.sum
@@ -1,211 +1,214 @@
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Unknwon/com v0.0.0-20181010210213-41959bdd855f h1:m1tYqjD/N0vF/S8s/ZKz/eccUr8RAAcrOK2MhXeTegA=
github.com/Unknwon/com v0.0.0-20181010210213-41959bdd855f/go.mod h1:KYCjqMOeHpNuTOiFQU6WEcTG7poCJrUs0YgyHNtn1no=
github.com/Unknwon/i18n v0.0.0-20171114194641-b64d33658966 h1:Mp8GNJ/tdTZIEdLdZfykEJaL3mTyEYrSzYNcdoQKpJk=
github.com/Unknwon/i18n v0.0.0-20171114194641-b64d33658966/go.mod h1:SFtfq0zFPsENI7DpE87QM2hcYu5QQ0fRdCgP+P1Hrqo=
github.com/alecthomas/gometalinter v2.0.11+incompatible/go.mod h1:qfIpQGGz3d+NmgyPBqv+LSh50emm1pt72EtcX2vKYQk=
github.com/alecthomas/gometalinter v3.0.0+incompatible h1:e9Zfvfytsw/e6Kd/PYd75wggK+/kX5Xn8IYDUKyc5fU=
github.com/alecthomas/gometalinter v3.0.0+incompatible/go.mod h1:qfIpQGGz3d+NmgyPBqv+LSh50emm1pt72EtcX2vKYQk=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737 h1:rRISKWyXfVxvoa702s91Zl5oREZTrR3yv+tXrrX7G/g=
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60=
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49 h1:jWNY1NDg6a/c8RSXkai7IX6UOhir0LD39I4Dukg+4Ks=
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ=
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
+github.com/clbanning/mxj v1.8.3/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/go-fed/httpsig v0.1.0/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
github.com/go-macaron/cache v0.0.0-20151013081102-561735312776 h1:UYIHS1r0WotqB5cIa0PAiV0m6GzD9rDBcn4alp5JgCw=
github.com/go-macaron/cache v0.0.0-20151013081102-561735312776/go.mod h1:hHAsZm/oBZVcY+S7qdQL6Vbg5VrXF6RuKGuqsszt3Ok=
github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191 h1:NjHlg70DuOkcAMqgt0+XA+NHwtu66MkTVVgR4fFWbcI=
github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191/go.mod h1:VFI2o2q9kYsC4o7VP1HrEVosiZZTd+MVT3YZx4gqvJw=
github.com/go-macaron/session v0.0.0-20190131233854-0a0a789bf193 h1:z/nqwd+ql/r6Q3QGnwNd6B89UjPytM0be5pDQV9TuWw=
github.com/go-macaron/session v0.0.0-20190131233854-0a0a789bf193/go.mod h1:ScEJm9Gk+ez5JJTml5WlBIqavAfuE5nF8e4Gvyz/X+A=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/gogits/gogs v0.11.86 h1:IujCpA+F/mYDXTcqdy593rl2donWakAWoL2HYZn7spw=
github.com/gogits/gogs v0.11.86/go.mod h1:H8FMbPPb+o/TgI6YnmQmT8nmEIHypXDau+f2CChYoCk=
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561 h1:aBzukfDxQlCTVS0NBUjI5YA3iVeaZ9Tb5PxNrrIP1xs=
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/gogs/go-libravatar v0.0.0-20161120025154-cd1abbd55d09 h1:UdOSIHZpkYcajRbfebBYzFDsL3SuqObH3bvKYBqgKmI=
github.com/gogs/go-libravatar v0.0.0-20161120025154-cd1abbd55d09/go.mod h1:Zas3BtO88pk1cwUfEYlvnl/CRwh0ybDxRWSwRjG8I3w=
github.com/gogs/gogs v0.11.86 h1:D+dXuY/6XjJ2t74W/dxo7ogx5+xW05Va8sJiQSS4WXA=
github.com/gogs/gogs v0.11.86/go.mod h1:qlbvdn16XTC6q7eR+thjW+OLdN+mi2PBZ8KqVT39T88=
github.com/gogs/minwinsvc v0.0.0-20170301035411-95be6356811a h1:8DZwxETOVWIinYxDK+i6L+rMb7eGATGaakD6ZucfHVk=
github.com/gogs/minwinsvc v0.0.0-20170301035411-95be6356811a/go.mod h1:TUIZ+29jodWQ8Gk6Pvtg4E09aMsc3C/VLZiVYfUhWQU=
github.com/golang/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 h1:6DVPu65tee05kY0/rciBQ47ue+AnuY8KTayV6VHikIo=
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf h1:7+FW5aGwISbqUtkfmIpZJGRgNFg2ioYPvFaUxdqpDsg=
github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf/go.mod h1:RpwtwJQFrIEPstU94h88MWPXP2ektJZ8cZ0YntAmXiE=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc h1:cJlkeAx1QYgO5N80aF5xRGstVsRQwgLR7uA2FnP1ZjY=
github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/feeds v1.1.0 h1:pcgLJhbdYgaUESnj3AmXPcB7cS3vy63+jC/TI14AGXk=
github.com/gorilla/feeds v1.1.0/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
github.com/gorilla/mux v1.7.0 h1:tOSd0UKHQd6urX6ApfOn4XdBMY6Sh1MfxV3kmaazO+U=
github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/schema v1.0.2 h1:sAgNfOcNYvdDSrzGHVy9nzCQahG+qmsg+nE8dK85QRA=
github.com/gorilla/schema v1.0.2/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU=
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/guregu/null v3.4.0+incompatible h1:a4mw37gBO7ypcBlTJeZGuMpSxxFTV9qFfFKgWxQSGaM=
github.com/guregu/null v3.4.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM=
github.com/ikeikeikeike/go-sitemap-generator v1.0.1 h1:49Fn8gro/B12vCY8pf5/+/Jpr3kwB9TvP0MSymo69SY=
github.com/ikeikeikeike/go-sitemap-generator v1.0.1/go.mod h1:QI+zWsz6yQyxkG9LWNcnu0f7aiAE5tPdsZOsICgmd1c=
+github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 h1:wIdDEle9HEy7vBPjC6oKz6ejs3Ut+jmsYvuOoAW2pSM=
+github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2/go.mod h1:WtaVKD9TeruTED9ydiaOJU08qGoEPP/LyzTKiD3jEsw=
github.com/imdario/mergo v0.3.7 h1:Y+UAYTZ7gDEuOfhxKWy+dvb5dRQ6rJjFSdX2HZY1/gI=
github.com/imdario/mergo v0.3.7/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/jteeuwen/go-bindata v3.0.7+incompatible h1:91Uy4d9SYVr1kyTJ15wJsog+esAZZl7JmEfTkwmhJts=
github.com/jteeuwen/go-bindata v3.0.7+incompatible/go.mod h1:JVvhzYOiGBnFSYRyV00iY8q7/0PThjIYav1p9h5dmKs=
github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE=
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU=
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec h1:ZXWuspqypleMuJy4bzYEqlMhJnGAYpLrWe5p7W3CdvI=
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec/go.mod h1:voECJzdraJmolzPBgL9Z7ANwXf4oMXaTCsIkdiPpR/g=
github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a h1:weJVJJRzAJBFRlAiJQROKQs8oC9vOxvm4rZmBBk0ONw=
github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8=
github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
github.com/manifoldco/promptui v0.3.2 h1:rir7oByTERac6jhpHUPErHuopoRDvO3jxS+FdadEns8=
github.com/manifoldco/promptui v0.3.2/go.mod h1:8JU+igZ+eeiiRku4T5BjtKh2ms8sziGpSYl1gN8Bazw=
github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.0 h1:v2XXALHHh6zHfYTJ+cSkwtyffnaOyR1MXaA91mTrb8o=
github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mcuadros/go-version v0.0.0-20180611085657-6d5863ca60fa h1:XvNrttGMJfVrUqblGju4IkjYXwx6l5OAAyjaIsydzsk=
github.com/mcuadros/go-version v0.0.0-20180611085657-6d5863ca60fa/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo=
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4=
github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
github.com/nicksnyder/go-i18n v1.10.0 h1:5AzlPKvXBH4qBzmZ09Ua9Gipyruv6uApMcrNZdo96+Q=
github.com/nicksnyder/go-i18n v1.10.0/go.mod h1:HrK7VCrbOvQoUAQ7Vpy7i87N7JZZZ7R2xBGjv0j365Q=
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ=
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca h1:NugYot0LIVPxTvN8n+Kvkn6TrbMyxQiuvKdEwFdR9vI=
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY=
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w=
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9 h1:vY5WqiEon0ZSTGM3ayVVi+twaHKHDFUVloaQ/wug9/c=
github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9/go.mod h1:q+QjxYvZ+fpjMXqs+XEriussHjSYqeXVnAdSV1tkMYk=
github.com/writeas/activity v0.1.2 h1:Y12B5lIrabfqKE7e7HFCWiXrlfXljr9tlkFm2mp7DgY=
github.com/writeas/activity v0.1.2/go.mod h1:mYYgiewmEM+8tlifirK/vl6tmB2EbjYaxwb+ndUw5T0=
github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE=
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2 h1:DUsp4OhdfI+e6iUqcPQlwx8QYXuUDsToTz/x82D3Zuo=
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc=
github.com/writeas/httpsig v1.0.0 h1:peIAoIA3DmlP8IG8tMNZqI4YD1uEnWBmkcC9OFPjt3A=
github.com/writeas/httpsig v1.0.0/go.mod h1:7ClMGSrSVXJbmiLa17bZ1LrG1oibGZmUMlh3402flPY=
github.com/writeas/impart v1.1.0 h1:nPnoO211VscNkp/gnzir5UwCDEvdHThL5uELU60NFSE=
github.com/writeas/impart v1.1.0/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y=
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219 h1:baEp0631C8sT2r/hqwypIw2snCFZa6h7U6TojoLHu/c=
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219/go.mod h1:NyM35ayknT7lzO6O/1JpfgGyv+0W9Z9q7aE0J8bXxfQ=
github.com/writeas/nerds v1.0.0 h1:ZzRcCN+Sr3MWID7o/x1cr1ZbLvdpej9Y1/Ho+JKlqxo=
github.com/writeas/nerds v1.0.0/go.mod h1:Gn2bHy1EwRcpXeB7ZhVmuUwiweK0e+JllNf66gvNLdU=
github.com/writeas/openssl-go v1.0.0 h1:YXM1tDXeYOlTyJjoMlYLQH1xOloUimSR1WMF8kjFc5o=
github.com/writeas/openssl-go v1.0.0/go.mod h1:WsKeK5jYl0B5y8ggOmtVjbmb+3rEGqSD25TppjJnETA=
github.com/writeas/saturday v1.7.1 h1:lYo1EH6CYyrFObQoA9RNWHVlpZA5iYL5Opxo7PYAnZE=
github.com/writeas/saturday v1.7.1/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
github.com/writeas/slug v1.2.0 h1:EMQ+cwLiOcA6EtFwUgyw3Ge18x9uflUnOnR6bp/J+/g=
github.com/writeas/slug v1.2.0/go.mod h1:RE8shOqQP3YhsfsQe0L3RnuejfQ4Mk+JjY5YJQFubfQ=
github.com/writeas/web-core v1.0.0 h1:5VKkCakQgdKZcbfVKJXtRpc5VHrkflusCl/KRCPzpQ0=
github.com/writeas/web-core v1.0.0/go.mod h1:Si3chV7VWgY8CsV+3gRolMXSO2Vx1ZFAQ/mkrpvmyEE=
github.com/writefreely/go-nodeinfo v1.1.0 h1:dp/ieEu0/gTeNKFvJTYhzBBouyFn7aiWtWzkb8J1JLg=
github.com/writefreely/go-nodeinfo v1.1.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg=
github.com/writefreely/go-nodeinfo v1.2.0 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAvRLR7F8ss=
github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg=
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59 h1:hk3yo72LXLapY9EXVttc3Z1rLOxT9IuAPPX3GpY2+jo=
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f h1:ETU2VEl7TnT5bl7IvuKEzTDpplg5wzGYsOCAPhdoEIg=
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 h1:rJm0LuqUjoDhSk2zO9ISMSToQxGz7Os2jRiOL8AWu4c=
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190206173232-65e2d4e15006 h1:bfLnR+k0tq5Lqt6dflRLcZiz6UaXCMt3vhYJ1l4FQ80=
golang.org/x/net v0.0.0-20190206173232-65e2d4e15006/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190209173611-3b5209105503 h1:5SvYFrOM3W8Mexn9/oA44Ji7vhXAZQ9hiP+1Q/DMrWg=
golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20181122213734-04b5d21e00f1/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 h1:bPP/rGuN1LUM0eaEwo6vnP6OfIWJzJBulzGUiKLjjSY=
golang.org/x/tools v0.0.0-20190208222737-3744606dbb67/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c h1:vTxShRUnK60yd8DZU+f95p1zSLj814+5CuEh7NjF2/Y=
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c/go.mod h1:3HH7i1SgMqlzxCcBmUHW657sD4Kvv9sC3HpL3YukzwA=
gopkg.in/bufio.v1 v1.0.0-20140618132640-567b2bfa514e h1:wGA78yza6bu/mWcc4QfBuIEHEtc06xdiU0X8sY36yUU=
gopkg.in/bufio.v1 v1.0.0-20140618132640-567b2bfa514e/go.mod h1:xsQCaysVCudhrYTfzYWe577fCe7Ceci+6qjO2Rdc0Z4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/clog.v1 v1.2.0 h1:BHfwHRNQy497iBNsRBassPixSAxRbn2z5KVkdBFbwxc=
gopkg.in/clog.v1 v1.2.0/go.mod h1:L6fgdpdhFgKX4eGuDvt+N6X2GwZE160NRrIHzvaF8ZM=
gopkg.in/ini.v1 v1.41.0 h1:Ka3ViY6gNYSKiVy71zXBEqKplnV35ImDLVG+8uoIklE=
gopkg.in/ini.v1 v1.41.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/macaron.v1 v1.3.2 h1:AvWIaPmwBUA87/OWzePkoxeaw6YJWDfBt1pDFPBnLf8=
gopkg.in/macaron.v1 v1.3.2/go.mod h1:PrsiawTWAGZs6wFbT5hlr7SQ2Ns9h7cUVtcUu4lQOVo=
gopkg.in/redis.v2 v2.3.2 h1:GPVIIB/JnL1wvfULefy3qXmPu1nfNu2d0yA09FHgwfs=
gopkg.in/redis.v2 v2.3.2/go.mod h1:4wl9PJ/CqzeHk3LVq1hNLHH8krm3+AXEgut4jVc++LU=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/handle.go b/handle.go
index 4f137b9..99c23ae 100644
--- a/handle.go
+++ b/handle.go
@@ -1,844 +1,848 @@
/*
* Copyright © 2018-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 writefreely
import (
"fmt"
"html/template"
"net/http"
"net/url"
"runtime/debug"
"strconv"
"strings"
"time"
"github.com/gorilla/sessions"
"github.com/writeas/impart"
"github.com/writeas/web-core/log"
"github.com/writeas/writefreely/config"
"github.com/writeas/writefreely/page"
)
// UserLevel represents the required user level for accessing an endpoint
type UserLevel int
const (
UserLevelNoneType UserLevel = iota // user or not -- ignored
UserLevelOptionalType // user or not -- object fetched if user
UserLevelNoneRequiredType // non-user (required)
UserLevelUserType // user (required)
)
func UserLevelNone(cfg *config.Config) UserLevel {
return UserLevelNoneType
}
func UserLevelOptional(cfg *config.Config) UserLevel {
return UserLevelOptionalType
}
func UserLevelNoneRequired(cfg *config.Config) UserLevel {
return UserLevelNoneRequiredType
}
func UserLevelUser(cfg *config.Config) UserLevel {
return UserLevelUserType
}
// UserLevelReader returns the permission level required for any route where
// users can read published content.
func UserLevelReader(cfg *config.Config) UserLevel {
if cfg.App.Private {
return UserLevelUserType
}
return UserLevelOptionalType
}
type (
handlerFunc func(app *App, w http.ResponseWriter, r *http.Request) error
userHandlerFunc func(app *App, u *User, w http.ResponseWriter, r *http.Request) error
userApperHandlerFunc func(apper Apper, u *User, w http.ResponseWriter, r *http.Request) error
dataHandlerFunc func(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error)
authFunc func(app *App, r *http.Request) (*User, error)
UserLevelFunc func(cfg *config.Config) UserLevel
)
type Handler struct {
errors *ErrorPages
sessionStore *sessions.CookieStore
app Apper
}
// ErrorPages hold template HTML error pages for displaying errors to the user.
// In each, there should be a defined template named "base".
type ErrorPages struct {
NotFound *template.Template
Gone *template.Template
InternalServerError *template.Template
Blank *template.Template
}
// NewHandler returns a new Handler instance, using the given StaticPage data,
// and saving alias to the application's CookieStore.
func NewHandler(apper Apper) *Handler {
h := &Handler{
errors: &ErrorPages{
NotFound: template.Must(template.New("").Parse("{{define \"base\"}}
404 Not found.
{{end}}")),
Gone: template.Must(template.New("").Parse("{{define \"base\"}}410 Gone.
{{end}}")),
InternalServerError: template.Must(template.New("").Parse("{{define \"base\"}}500 Internal server error.
{{end}}")),
Blank: template.Must(template.New("").Parse("{{define \"base\"}}{{.Title}} {{.Content}}
{{end}}")),
},
sessionStore: apper.App().sessionStore,
app: apper,
}
return h
}
// NewWFHandler returns a new Handler instance, using WriteFreely template files.
// You MUST call writefreely.InitTemplates() before this.
func NewWFHandler(apper Apper) *Handler {
h := NewHandler(apper)
h.SetErrorPages(&ErrorPages{
NotFound: pages["404-general.tmpl"],
Gone: pages["410.tmpl"],
InternalServerError: pages["500.tmpl"],
Blank: pages["blank.tmpl"],
})
return h
}
// SetErrorPages sets the given set of ErrorPages as templates for any errors
// that come up.
func (h *Handler) SetErrorPages(e *ErrorPages) {
h.errors = e
}
// User handles requests made in the web application by the authenticated user.
// This provides user-friendly HTML pages and actions that work in the browser.
func (h *Handler) User(f userHandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleHTTPError(w, r, func() error {
var status int
start := time.Now()
defer func() {
if e := recover(); e != nil {
log.Error("%s: %s", e, debug.Stack())
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
status = http.StatusInternalServerError
}
- log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
+ log.Info(h.app.ReqLog(r, status, time.Since(start)))
}()
u := getUserSession(h.app.App(), r)
if u == nil {
err := ErrNotLoggedIn
status = err.Status
return err
}
err := f(h.app.App(), u, w, r)
if err == nil {
status = http.StatusOK
} else if err, ok := err.(impart.HTTPError); ok {
status = err.Status
} else {
status = http.StatusInternalServerError
}
return err
}())
}
}
// Admin handles requests on /admin routes
func (h *Handler) Admin(f userHandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleHTTPError(w, r, func() error {
var status int
start := time.Now()
defer func() {
if e := recover(); e != nil {
log.Error("%s: %s", e, debug.Stack())
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
status = http.StatusInternalServerError
}
- log.Info(fmt.Sprintf("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent()))
+ log.Info(h.app.ReqLog(r, status, time.Since(start)))
}()
u := getUserSession(h.app.App(), r)
if u == nil || !u.IsAdmin() {
err := impart.HTTPError{http.StatusNotFound, ""}
status = err.Status
return err
}
err := f(h.app.App(), u, w, r)
if err == nil {
status = http.StatusOK
} else if err, ok := err.(impart.HTTPError); ok {
status = err.Status
} else {
status = http.StatusInternalServerError
}
return err
}())
}
}
// AdminApper handles requests on /admin routes that require an Apper.
func (h *Handler) AdminApper(f userApperHandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleHTTPError(w, r, func() error {
var status int
start := time.Now()
defer func() {
if e := recover(); e != nil {
log.Error("%s: %s", e, debug.Stack())
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
status = http.StatusInternalServerError
}
- log.Info(fmt.Sprintf("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent()))
+ log.Info(h.app.ReqLog(r, status, time.Since(start)))
}()
u := getUserSession(h.app.App(), r)
if u == nil || !u.IsAdmin() {
err := impart.HTTPError{http.StatusNotFound, ""}
status = err.Status
return err
}
err := f(h.app, u, w, r)
if err == nil {
status = http.StatusOK
} else if err, ok := err.(impart.HTTPError); ok {
status = err.Status
} else {
status = http.StatusInternalServerError
}
return err
}())
}
}
func apiAuth(app *App, r *http.Request) (*User, error) {
// Authorize user from Authorization header
t := r.Header.Get("Authorization")
if t == "" {
return nil, ErrNoAccessToken
}
u := &User{ID: app.db.GetUserID(t)}
if u.ID == -1 {
return nil, ErrBadAccessToken
}
return u, nil
}
// optionaAPIAuth is used for endpoints that accept authenticated requests via
// Authorization header or cookie, unlike apiAuth. It returns a different err
// in the case where no Authorization header is present.
func optionalAPIAuth(app *App, r *http.Request) (*User, error) {
// Authorize user from Authorization header
t := r.Header.Get("Authorization")
if t == "" {
return nil, ErrNotLoggedIn
}
u := &User{ID: app.db.GetUserID(t)}
if u.ID == -1 {
return nil, ErrBadAccessToken
}
return u, nil
}
func webAuth(app *App, r *http.Request) (*User, error) {
u := getUserSession(app, r)
if u == nil {
return nil, ErrNotLoggedIn
}
return u, nil
}
// UserAPI handles requests made in the API by the authenticated user.
// This provides user-friendly HTML pages and actions that work in the browser.
func (h *Handler) UserAPI(f userHandlerFunc) http.HandlerFunc {
return h.UserAll(false, f, apiAuth)
}
func (h *Handler) UserAll(web bool, f userHandlerFunc, a authFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
handleFunc := func() error {
var status int
start := time.Now()
defer func() {
if e := recover(); e != nil {
log.Error("%s: %s", e, debug.Stack())
impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "Something didn't work quite right."})
status = 500
}
- log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
+ log.Info(h.app.ReqLog(r, status, time.Since(start)))
}()
u, err := a(h.app.App(), r)
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
status = err.Status
} else {
status = 500
}
return err
}
err = f(h.app.App(), u, w, r)
if err == nil {
status = 200
} else if err, ok := err.(impart.HTTPError); ok {
status = err.Status
} else {
status = 500
}
return err
}
if web {
h.handleHTTPError(w, r, handleFunc())
} else {
h.handleError(w, r, handleFunc())
}
}
}
func (h *Handler) RedirectOnErr(f handlerFunc, loc string) handlerFunc {
return func(app *App, w http.ResponseWriter, r *http.Request) error {
err := f(app, w, r)
if err != nil {
if ie, ok := err.(impart.HTTPError); ok {
// Override default redirect with returned error's, if it's a
// redirect error.
if ie.Status == http.StatusFound {
return ie
}
}
return impart.HTTPError{http.StatusFound, loc}
}
return nil
}
}
func (h *Handler) Page(n string) http.HandlerFunc {
return h.Web(func(app *App, w http.ResponseWriter, r *http.Request) error {
t, ok := pages[n]
if !ok {
return impart.HTTPError{http.StatusNotFound, "Page not found."}
}
sp := pageForReq(app, r)
err := t.ExecuteTemplate(w, "base", sp)
if err != nil {
log.Error("Unable to render page: %v", err)
}
return err
}, UserLevelOptional)
}
func (h *Handler) WebErrors(f handlerFunc, ul UserLevelFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: factor out this logic shared with Web()
h.handleHTTPError(w, r, func() error {
var status int
start := time.Now()
defer func() {
if e := recover(); e != nil {
u := getUserSession(h.app.App(), r)
username := "None"
if u != nil {
username = u.Username
}
log.Error("User: %s\n\n%s: %s", username, e, debug.Stack())
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
status = 500
}
- log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
+ log.Info(h.app.ReqLog(r, status, time.Since(start)))
}()
var session *sessions.Session
var err error
if ul(h.app.App().cfg) != UserLevelNoneType {
session, err = h.sessionStore.Get(r, cookieName)
if err != nil && (ul(h.app.App().cfg) == UserLevelNoneRequiredType || ul(h.app.App().cfg) == UserLevelUserType) {
// Cookie is required, but we can ignore this error
log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul(h.app.App().cfg), err)
}
_, gotUser := session.Values[cookieUserVal].(*User)
if ul(h.app.App().cfg) == UserLevelNoneRequiredType && gotUser {
to := correctPageFromLoginAttempt(r)
log.Info("Handler: Required NO user, but got one. Redirecting to %s", to)
err := impart.HTTPError{http.StatusFound, to}
status = err.Status
return err
} else if ul(h.app.App().cfg) == UserLevelUserType && !gotUser {
log.Info("Handler: Required a user, but DIDN'T get one. Sending not logged in.")
err := ErrNotLoggedIn
status = err.Status
return err
}
}
// TODO: pass User object to function
err = f(h.app.App(), w, r)
if err == nil {
status = 200
} else if httpErr, ok := err.(impart.HTTPError); ok {
status = httpErr.Status
if status < 300 || status > 399 {
addSessionFlash(h.app.App(), w, r, httpErr.Message, session)
return impart.HTTPError{http.StatusFound, r.Referer()}
}
} else {
e := fmt.Sprintf("[Web handler] 500: %v", err)
if !strings.HasSuffix(e, "write: broken pipe") {
log.Error(e)
} else {
log.Error(e)
}
log.Info("Web handler internal error render")
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
status = 500
}
return err
}())
}
}
func (h *Handler) CollectionPostOrStatic(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, ".") && !isRaw(r) {
start := time.Now()
status := 200
defer func() {
- log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
+ log.Info(h.app.ReqLog(r, status, time.Since(start)))
}()
// Serve static file
h.app.App().shttp.ServeHTTP(w, r)
return
}
h.Web(viewCollectionPost, UserLevelReader)(w, r)
}
// Web handles requests made in the web application. This provides user-
// friendly HTML pages and actions that work in the browser.
func (h *Handler) Web(f handlerFunc, ul UserLevelFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleHTTPError(w, r, func() error {
var status int
start := time.Now()
defer func() {
if e := recover(); e != nil {
u := getUserSession(h.app.App(), r)
username := "None"
if u != nil {
username = u.Username
}
log.Error("User: %s\n\n%s: %s", username, e, debug.Stack())
log.Info("Web deferred internal error render")
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
status = 500
}
- log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
+ log.Info(h.app.ReqLog(r, status, time.Since(start)))
}()
if ul(h.app.App().cfg) != UserLevelNoneType {
session, err := h.sessionStore.Get(r, cookieName)
if err != nil && (ul(h.app.App().cfg) == UserLevelNoneRequiredType || ul(h.app.App().cfg) == UserLevelUserType) {
// Cookie is required, but we can ignore this error
log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul(h.app.App().cfg), err)
}
_, gotUser := session.Values[cookieUserVal].(*User)
if ul(h.app.App().cfg) == UserLevelNoneRequiredType && gotUser {
to := correctPageFromLoginAttempt(r)
log.Info("Handler: Required NO user, but got one. Redirecting to %s", to)
err := impart.HTTPError{http.StatusFound, to}
status = err.Status
return err
} else if ul(h.app.App().cfg) == UserLevelUserType && !gotUser {
log.Info("Handler: Required a user, but DIDN'T get one. Sending not logged in.")
err := ErrNotLoggedIn
status = err.Status
return err
}
}
// TODO: pass User object to function
err := f(h.app.App(), w, r)
if err == nil {
status = 200
} else if httpErr, ok := err.(impart.HTTPError); ok {
status = httpErr.Status
} else {
e := fmt.Sprintf("[Web handler] 500: %v", err)
log.Error(e)
log.Info("Web internal error render")
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
status = 500
}
return err
}())
}
}
func (h *Handler) All(f handlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleError(w, r, func() error {
// TODO: return correct "success" status
status := 200
start := time.Now()
defer func() {
if e := recover(); e != nil {
log.Error("%s:\n%s", e, debug.Stack())
impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "Something didn't work quite right."})
status = 500
}
- log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
+ log.Info(h.app.ReqLog(r, status, time.Since(start)))
}()
// TODO: do any needed authentication
err := f(h.app.App(), w, r)
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
status = err.Status
} else {
status = 500
}
}
return err
}())
}
}
func (h *Handler) AllReader(f handlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleError(w, r, func() error {
status := 200
start := time.Now()
defer func() {
if e := recover(); e != nil {
log.Error("%s:\n%s", e, debug.Stack())
impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "Something didn't work quite right."})
status = 500
}
- log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
+ log.Info(h.app.ReqLog(r, status, time.Since(start)))
}()
if h.app.App().cfg.App.Private {
// This instance is private, so ensure it's being accessed by a valid user
// Check if authenticated with an access token
_, apiErr := optionalAPIAuth(h.app.App(), r)
if apiErr != nil {
if err, ok := apiErr.(impart.HTTPError); ok {
status = err.Status
} else {
status = 500
}
if apiErr == ErrNotLoggedIn {
// Fall back to web auth since there was no access token given
_, err := webAuth(h.app.App(), r)
if err != nil {
if err, ok := apiErr.(impart.HTTPError); ok {
status = err.Status
} else {
status = 500
}
return err
}
} else {
return apiErr
}
}
}
err := f(h.app.App(), w, r)
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
status = err.Status
} else {
status = 500
}
}
return err
}())
}
}
func (h *Handler) Download(f dataHandlerFunc, ul UserLevelFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleHTTPError(w, r, func() error {
var status int
start := time.Now()
defer func() {
if e := recover(); e != nil {
log.Error("%s: %s", e, debug.Stack())
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
status = 500
}
- log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
+ log.Info(h.app.ReqLog(r, status, time.Since(start)))
}()
data, filename, err := f(h.app.App(), w, r)
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
status = err.Status
} else {
status = 500
}
return err
}
ext := ".json"
ct := "application/json"
if strings.HasSuffix(r.URL.Path, ".csv") {
ext = ".csv"
ct = "text/csv"
} else if strings.HasSuffix(r.URL.Path, ".zip") {
ext = ".zip"
ct = "application/zip"
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s%s", filename, ext))
w.Header().Set("Content-Type", ct)
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
fmt.Fprint(w, string(data))
status = 200
return nil
}())
}
}
func (h *Handler) Redirect(url string, ul UserLevelFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleHTTPError(w, r, func() error {
start := time.Now()
var status int
if ul(h.app.App().cfg) != UserLevelNoneType {
session, err := h.sessionStore.Get(r, cookieName)
if err != nil && (ul(h.app.App().cfg) == UserLevelNoneRequiredType || ul(h.app.App().cfg) == UserLevelUserType) {
// Cookie is required, but we can ignore this error
log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul(h.app.App().cfg), err)
}
_, gotUser := session.Values[cookieUserVal].(*User)
if ul(h.app.App().cfg) == UserLevelNoneRequiredType && gotUser {
to := correctPageFromLoginAttempt(r)
log.Info("Handler: Required NO user, but got one. Redirecting to %s", to)
err := impart.HTTPError{http.StatusFound, to}
status = err.Status
return err
} else if ul(h.app.App().cfg) == UserLevelUserType && !gotUser {
log.Info("Handler: Required a user, but DIDN'T get one. Sending not logged in.")
err := ErrNotLoggedIn
status = err.Status
return err
}
}
status = sendRedirect(w, http.StatusFound, url)
- log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
+ log.Info(h.app.ReqLog(r, status, time.Since(start)))
return nil
}())
}
}
func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err error) {
if err == nil {
return
}
if err, ok := err.(impart.HTTPError); ok {
if err.Status >= 300 && err.Status < 400 {
sendRedirect(w, err.Status, err.Message)
return
} else if err.Status == http.StatusUnauthorized {
q := ""
if r.URL.RawQuery != "" {
q = url.QueryEscape("?" + r.URL.RawQuery)
}
sendRedirect(w, http.StatusFound, "/login?to="+r.URL.Path+q)
return
} else if err.Status == http.StatusGone {
w.WriteHeader(err.Status)
p := &struct {
page.StaticPage
Content *template.HTML
}{
StaticPage: pageForReq(h.app.App(), r),
}
if err.Message != "" {
co := template.HTML(err.Message)
p.Content = &co
}
h.errors.Gone.ExecuteTemplate(w, "base", p)
return
} else if err.Status == http.StatusNotFound {
w.WriteHeader(err.Status)
+ if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
+ // This is a fediverse request; simply return the header
+ return
+ }
h.errors.NotFound.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
return
} else if err.Status == http.StatusInternalServerError {
w.WriteHeader(err.Status)
log.Info("handleHTTPErorr internal error render")
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
return
} else if err.Status == http.StatusAccepted {
impart.WriteSuccess(w, "", err.Status)
return
} else {
p := &struct {
page.StaticPage
Title string
Content template.HTML
}{
pageForReq(h.app.App(), r),
fmt.Sprintf("Uh oh (%d)", err.Status),
template.HTML(fmt.Sprintf("%s
", err.Message)),
}
h.errors.Blank.ExecuteTemplate(w, "base", p)
return
}
impart.WriteError(w, err)
return
}
impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "This is an unhelpful error message for a miscellaneous internal error."})
}
func (h *Handler) handleError(w http.ResponseWriter, r *http.Request, err error) {
if err == nil {
return
}
if err, ok := err.(impart.HTTPError); ok {
if err.Status >= 300 && err.Status < 400 {
sendRedirect(w, err.Status, err.Message)
return
}
// if strings.Contains(r.Header.Get("Accept"), "text/html") {
impart.WriteError(w, err)
// }
return
}
if IsJSON(r.Header.Get("Content-Type")) {
impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "This is an unhelpful error message for a miscellaneous internal error."})
return
}
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
}
func correctPageFromLoginAttempt(r *http.Request) string {
to := r.FormValue("to")
if to == "" {
to = "/"
} else if !strings.HasPrefix(to, "/") {
to = "/" + to
}
return to
}
func (h *Handler) LogHandlerFunc(f http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleHTTPError(w, r, func() error {
status := 200
start := time.Now()
defer func() {
if e := recover(); e != nil {
log.Error("Handler.LogHandlerFunc\n\n%s: %s", e, debug.Stack())
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
status = 500
}
// TODO: log actual status code returned
- log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
+ log.Info(h.app.ReqLog(r, status, time.Since(start)))
}()
if h.app.App().cfg.App.Private {
// This instance is private, so ensure it's being accessed by a valid user
// Check if authenticated with an access token
_, apiErr := optionalAPIAuth(h.app.App(), r)
if apiErr != nil {
if err, ok := apiErr.(impart.HTTPError); ok {
status = err.Status
} else {
status = 500
}
if apiErr == ErrNotLoggedIn {
// Fall back to web auth since there was no access token given
_, err := webAuth(h.app.App(), r)
if err != nil {
if err, ok := apiErr.(impart.HTTPError); ok {
status = err.Status
} else {
status = 500
}
return err
}
} else {
return apiErr
}
}
}
f(w, r)
return nil
}())
}
}
func sendRedirect(w http.ResponseWriter, code int, location string) int {
w.Header().Set("Location", location)
w.WriteHeader(code)
return code
}
diff --git a/less/core.less b/less/core.less
index 2c918bd..7e39f35 100644
--- a/less/core.less
+++ b/less/core.less
@@ -1,1484 +1,1488 @@
@primary: rgb(114, 120, 191);
@secondary: rgb(114, 191, 133);
@subheaders: #444;
@headerTextColor: black;
@sansFont: 'Open Sans', 'Segoe UI', Tahoma, Arial, sans-serif;
@serifFont: Lora, 'Palatino Linotype', 'Book Antiqua', 'New York', 'DejaVu serif', serif;
@monoFont: Hack, consolas, Menlo-Regular, Menlo, Monaco, 'ubuntu mono', monospace, monospace;
@dangerCol: #e21d27;
@errUrgentCol: #ecc63c;
@proSelectedCol: #71D571;
@textLinkColor: rgb(0, 0, 238);
body {
font-family: @serifFont;
font-size-adjust: 0.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: white;
color: #111;
h1, header h2 {
a {
color: @headerTextColor;
.transition-duration(0.2s);
&:hover {
color: #303030;
text-decoration: none;
}
}
}
h1, h2, h3 {
line-height: 1.2;
}
post article, collection article p, subpage article p {
display: block;
unicode-bidi: embed;
white-space: pre;
}
post {
#wrapper, pre {
max-width: 40em;
margin: 0 auto;
a:hover {
text-decoration: underline;
}
}
blockquote {
p + p {
margin: -2em 0 0.5em;
}
}
article {
margin-bottom: 2em !important;
h1, h2, h3, h4, h5, h6, p, ul, ol, code {
display: inline;
margin: 0;
}
hr + p, ol, ul {
display: block;
margin-top: -1rem;
margin-bottom: -1rem;
}
ol, ul {
margin: 2rem 0 -1rem;
ol, ul {
margin: 1.25rem 0 -0.5rem;
}
}
li {
margin-top: -0.5rem;
margin-bottom: -0.5rem;
}
h2#title {
.article-title;
}
h1 {
font-size: 1.5em;
}
h2 {
font-size: 1.17em;
}
}
header {
nav {
span, a {
&.pinned {
&.selected {
font-weight: bold;
}
&+.views {
margin-left: 2em;
}
}
}
}
}
.owner-visible {
display: none;
}
}
post, collection, subpage {
code {
.article-code;
}
- img, video {
+ img, video, audio {
max-width: 100%;
}
+ audio {
+ width: 100%;
+ white-space: initial;
+ }
pre {
.code-block;
code {
background: transparent;
border: 0;
padding: 0;
font-size: 1em;
white-space: pre-wrap; /* CSS 3 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */
white-space: -o-pre-wrap; /* Opera 7 */
word-wrap: break-word; /* Internet Explorer 5.5+ */
}
}
blockquote {
.article-blockquote;
}
article {
hr {
margin-top: 0;
margin-bottom: 0;
}
p.badge {
background-color: #aaa;
display: inline-block;
padding: 0.25em 0.5em;
margin: 0;
float: right;
color: white;
.rounded(.25em);
}
}
header {
nav {
span, a {
&.pinned {
&+.pinned {
margin-left: 1.5em;
}
}
}
}
}
footer {
nav {
a {
margin-top: 0;
}
}
}
}
collection {
#welcome, .access {
margin: 0 auto;
max-width: 35em;
h2 {
font-weight: normal;
margin-bottom: 1em;
}
p {
font-size: 1.2em;
line-height: 1.6;
}
}
.access {
margin: 8em auto;
text-align: center;
h2, ul.errors {
font-size: 1.2em;
margin-bottom: 1.5em !important;
}
}
header {
padding: 0 1em;
text-align: center;
max-width: 50em;
margin: 3em auto 4em;
.writeas-prefix {
a {
color: #aaa;
}
display: block;
margin-bottom: 0.5em;
}
nav {
display: block;
margin: 1em 0;
a:first-child {
margin: 0;
}
}
}
nav#manage {
position: absolute;
top: 1em;
left: 1.5em;
li a.write {
font-family: @serifFont;
padding-top: 0.2em;
padding-bottom: 0.2em;
}
}
pre {
line-height: 1.5;
}
}
subpage {
#wrapper {
h1 {
font-size: 2.5em;
letter-spacing: -2px;
padding: 0 2rem 2rem;
}
}
}
post {
pre {
font-size: 0.75em;
}
}
collection, subpage {
#wrapper {
margin-left: auto;
margin-right: auto;
article {
margin-bottom: 4em;
&:hover {
.hidden {
.opacity(1);
}
}
}
h2 {
margin-top: 0em;
margin-bottom: 0.25em;
&+time {
display: block;
margin-top: 0.25em;
margin-bottom: 0.25em;
}
}
time {
font-size: 1.1em;
&+p {
margin-top: 0.25em;
}
}
footer {
text-align: left;
padding: 0;
}
}
#paging {
overflow: visible;
padding: 1em 6em 0;
}
a.read-more {
color: #666;
}
}
me #official-writing {
h2 {
font-weight: normal;
a {
font-size: 0.6em;
margin-left: 1em;
}
a[name] {
margin-left: 0;
}
a:link, a:visited {
color: @textLinkColor;
}
a:hover {
text-decoration: underline;
}
}
}
promo {
div.heading {
margin: 8em 0;
}
div.heading, div.attention-form {
h1 {
font-size: 3.5em;
}
input {
padding-left: 0.75em;
padding-right: 0.75em;
&[type=email] {
max-width: 16em;
}
&[type=submit] {
padding-left: 1.5em;
padding-right: 1.5em;
}
}
}
h2 {
margin-bottom: 0;
font-size: 1.8em;
font-weight: normal;
span.write-as {
color: black;
}
&.soon {
color: lighten(@subheaders, 50%);
span {
&.write-as {
color: lighten(#000, 50%);
}
&.note {
color: lighten(#333, 50%);
font-variant: small-caps;
margin-left: 0.5em;
}
}
}
}
.half-col a {
margin-left: 1em;
margin-right: 1em;
}
}
nav#top-nav {
display: inline;
position: absolute;
top: 1.5em;
right: 1.5em;
font-size: 0.95rem;
font-family: @sansFont;
text-transform: uppercase;
a {
color: #777;
}
a + a {
margin-left: 1em;
}
}
footer {
nav, ul {
a {
display: inline-block;
margin-top: 0.8em;
.transition-duration(0.1s);
text-decoration: none;
+ a {
margin-left: 0.8em;
}
&:link, &:visited {
color: #999;
}
&:hover {
color: #666;
text-decoration: none;
}
}
}
a.home {
&:link, &:visited {
color: #333;
}
font-weight: bold;
text-decoration: none;
&:hover {
color: #000;
}
}
ul {
list-style: none;
text-align: left;
padding-left: 0 !important;
margin-left: 0 !important;
.icons img {
height: 16px;
width: 16px;
fill: #999;
}
}
}
}
.post-title {
a {
&:link {
color: #333;
}
&:visited {
color: #444;
}
}
time, time a:link, time a:visited, &+.time {
color: #999;
}
}
.hidden {
-moz-transition-property: opacity;
-webkit-transition-property: opacity;
-o-transition-property: opacity;
transition-property: opacity;
.transition-duration(0.4s);
.opacity(0);
}
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
&.subdued {
color: #999;
&:hover {
border-bottom: 1px solid #999;
text-decoration: none;
}
}
&.danger {
color: @dangerCol;
font-size: 0.86em;
}
&.simple-cta {
text-decoration: none;
border-bottom: 1px solid #ccc;
color: #333;
padding-bottom: 2px;
&:hover {
text-decoration: none;
}
}
&.action-btn {
font-family: @sansFont;
text-transform: uppercase;
.rounded(.25em);
background-color: red;
color: white;
font-weight: bold;
padding: 0.5em 0.75em;
&:hover {
background-color: lighten(#f00, 5%);
text-decoration: none;
}
}
&.hashtag:hover {
text-decoration: none;
span + span {
text-decoration: underline;
}
}
&.hashtag {
span:first-child {
color: #999;
margin-right: 0.1em;
font-size: 0.86em;
text-decoration: none;
}
}
}
abbr {
border-bottom: 1px dotted #999;
text-decoration: none;
cursor: help;
}
body#collection article p, body#subpage article p {
.article-p;
}
pre, body#post article, body#collection article, body#subpage article, body#subpage #wrapper h1 {
max-width: 40rem;
margin: 0 auto;
}
textarea, pre, body#post article, body#collection article p {
&.norm, &.sans, &.wrap {
line-height: 1.4em;
white-space: pre-wrap; /* CSS 3 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */
white-space: -o-pre-wrap; /* Opera 7 */
word-wrap: break-word; /* Internet Explorer 5.5+ */
}
}
textarea, pre, body#post article, body#collection article, body#subpage article, span, .font {
&.norm {
font-family: @serifFont;
}
&.sans {
font-family: @sansFont;
}
&.mono, &.wrap, &.code {
font-family: @monoFont;
}
&.mono, &.code {
max-width: none !important;
}
}
textarea {
&.section {
border: 1px solid #ccc;
padding: 0.65em 0.75em;
.rounded(.25em);
&.codable {
height: 12em;
resize: vertical;
}
}
}
.ace_editor {
height: 12em;
border: 1px solid #333;
max-width: initial;
width: 100%;
font-size: 0.86em !important;
border: 1px solid #ccc;
padding: 0.65em 0.75em;
margin: 0;
.rounded(.25em);
}
p {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
&.intro {
font-size: 1.25em;
text-align: center;
}
&.upgrade-prompt {
font-size: 0.9em;
color: #444;
}
&.text-cta {
font-size: 1.2em;
text-align: center;
margin-bottom: 0.5em;
&+ p {
text-align: center;
font-size: 0.7em;
margin-top: 0;
color: #666;
}
}
&.error {
font-style: italic;
color: @errUrgentCol;
}
&.headeresque {
font-size: 2em;
}
}
table.classy {
width: 95%;
border-collapse: collapse;
margin-bottom: 2em;
tr + tr {
border-top: 1px solid #ccc;
}
th {
text-transform: uppercase;
font-weight: normal;
font-size: 95%;
font-family: @sansFont;
padding: 1rem 0.75rem;
text-align: center;
}
td {
height: 3.5rem;
}
p {
margin-top: 0 !important;
margin-bottom: 0 !important;
}
&.export {
.disabled {
color: #999;
}
.disabled, a {
text-transform: lowercase;
}
}
}
body#collection article, body#subpage article {
padding-top: 0;
padding-bottom: 0;
.book {
h2 {
font-size: 1.4em;
}
a.hidden.action {
color: #666;
float: right;
font-size: 1em;
margin-left: 1em;
margin-bottom: 1em;
}
}
}
body#post article {
p.badge {
font-size: 0.9em;
}
}
article {
h2.post-title a[rel=nofollow]::after {
content: '\a0 \2934';
}
}
table.downloads {
width: 100%;
td {
text-align: center;
}
img.os {
width: 48px;
vertical-align: middle;
margin-bottom: 6px;
}
}
select.inputform, textarea.inputform {
border: 1px solid #999;
}
input, button, select.inputform, textarea.inputform {
padding: 0.5em;
font-family: @serifFont;
font-size: 100%;
.rounded(.25em);
&[type=submit], &.submit {
border: 1px solid @primary;
background: @primary;
color: white;
.transition(0.2s);
&:hover {
background-color: lighten(@primary, 3%);
}
&:disabled {
cursor: default;
background-color: desaturate(@primary, 100%) !important;
border-color: desaturate(@primary, 100%) !important;
}
}
&.error[type=text], textarea.error {
-webkit-transition: all 0.30s ease-in-out;
-moz-transition: all 0.30s ease-in-out;
-ms-transition: all 0.30s ease-in-out;
-o-transition: all 0.30s ease-in-out;
outline: none;
}
&.danger {
border: 1px solid @dangerCol;
background: @dangerCol;
color: white;
&:hover {
background-color: lighten(@dangerCol, 3%);
}
}
&.error[type=text]:focus, textarea.error:focus {
box-shadow: 0 0 5px @errUrgentCol;
border: 1px solid @errUrgentCol;
}
}
div.flat-select {
display: inline-block;
position: relative;
select {
border: 0;
background: 0;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
opacity: 0;
}
&.action {
&:hover {
label {
text-decoration: underline;
}
}
label, select {
cursor: pointer;
}
}
}
input {
&.underline{
border: none;
border-bottom: 1px solid #ccc;
padding: 0 .2em .2em;
font-size: 0.9em;
color: #333;
}
&.inline {
padding: 0.2rem 0.2rem;
margin-left: 0;
font-size: 1em;
border: 0 !important;
border-bottom: 1px solid #999 !important;
width: 7em;
.rounded(0);
}
&[type=tel], &[type=text], &[type=email], &[type=password] {
border: 1px solid #999;
}
&.boxy {
border: 1px solid #999 !important;
}
}
#beta, .content-container {
max-width: 50em;
margin: 0 auto 3em;
font-size: 1.2em;
&.tight {
max-width: 30em;
}
&.snug {
max-width: 40em;
}
&.regular {
font-size: 1em;
}
.app {
+ .app {
margin-top: 1.5em;
}
h2 {
margin-bottom: 0.25em;
}
p {
margin-top: 0.25em;
}
}
h2.intro {
font-weight: normal;
}
p {
line-height: 1.4;
}
li {
margin: 0.3em 0;
}
h2 {
&.light {
font-weight: normal;
}
a {
.transition-duration(0.2s);
-moz-transition-property: color;
-webkit-transition-property: color;
-o-transition-property: color;
transition-property: color;
&:link, &:visited, &:hover {
color: @subheaders;
}
&:hover {
color: lighten(@subheaders, 10%);
text-decoration: none;
}
}
}
}
.content-container {
pricing {
button {
cursor: pointer;
color: white;
margin-top: 1em;
margin-bottom: 1em;
padding-left: 1.5em;
padding-right: 1.5em;
border: 0;
background: @primary;
.rounded(.25em);
.transition(0.2s);
&:hover {
background-color: lighten(@primary, 5%);
}
&.unselected {
cursor: pointer;
}
}
h2 span {
font-weight: normal;
}
.half {
margin: 0 0 1em 0;
text-align: center;
}
}
div.features {
margin-top: 1.5em;
text-align: center;
font-size: 0.86em;
ul {
text-align: left;
max-width: 26em;
margin-left: auto !important;
margin-right: auto !important;
li.soon, span.soon {
color: lighten(#111, 40%);
}
}
}
div.blurbs {
>h2 {
text-align: center;
color: #333;
font-weight: normal;
}
p.price {
font-size: 1.2em;
margin-bottom: 0;
color: #333;
margin-top: 0.5em;
&+p {
margin-top: 0;
font-size: 0.8em;
}
}
p.text-cta {
font-size: 1em;
}
}
}
footer div.blurbs {
display: flex;
flex-flow: row;
flex-wrap: wrap;
}
div.blurbs {
.half, .third, .fourth {
font-size: 0.86em;
h3 {
font-weight: normal;
}
p, ul {
color: #595959;
}
hr {
margin: 1em 0;
}
}
.half {
padding: 0 1em 0 0;
width: ~"calc(50% - 1em)";
&+.half {
padding: 0 0 0 1em;
}
}
.third {
padding: 0;
width: ~"calc(33% - 1em)";
&+.third {
padding: 0 0 0 1em;
}
}
.fourth {
flex: 1 1 25%;
-webkit-flex: 1 1 25%;
h3 {
margin-bottom: 0.5em;
}
ul {
margin-top: 0.5em;
}
}
}
.contain-me {
text-align: left;
margin: 0 auto 4em;
max-width: 50em;
h2 + p, h2 + p + p, p.describe-me {
margin-left: 1.5em;
margin-right: 1.5em;
color: #333;
}
}
footer.contain-me {
font-size: 1.1em;
}
#official-writing, #wrapper {
h2, h3, h4 {
color: @subheaders;
}
ul {
&.collections {
margin-left: 0;
li {
&.collection {
a.title {
&:link, &:visited {
color: @headerTextColor;
}
}
}
a.create {
color: #444;
}
}
& + p {
margin-top: 2em;
margin-left: 1em;
}
}
}
}
#official-writing, #wrapper {
h2 {
&.major {
color: #222;
}
&.bugfix {
color: #666;
}
+.android-version {
a {
color: #999;
&:hover {
text-decoration: underline;
}
}
}
}
}
li {
line-height: 1.4;
.item-desc, .prog-lang {
font-size: 0.6em;
font-family: 'Open Sans', sans-serif;
font-weight: bold;
margin-left: 0.5em;
margin-right: 0.5em;
text-transform: uppercase;
color: #999;
}
}
.success {
color: darken(@proSelectedCol, 20%);
}
.alert {
padding: 1em;
margin-bottom: 1.25em;
border: 1px solid transparent;
.rounded(.25em);
&.info {
color: #31708f;
background-color: #d9edf7;
border-color: #bce8f1;
}
&.success {
color: #3c763d;
background-color: #dff0d8;
border-color: #d6e9c6;
}
p {
margin: 0;
&+p {
margin-top: 0.5em;
}
}
p.dismiss {
font-family: @sansFont;
text-align: right;
font-size: 0.86em;
text-transform: uppercase;
}
}
ul.errors {
padding: 0;
text-indent: 0;
li.urgent {
list-style: none;
font-style: italic;
text-align: center;
color: @errUrgentCol;
a:link, a:visited {
color: purple;
}
}
li.info {
list-style: none;
font-size: 1.1em;
text-align: center;
}
}
body#pad #target a.upgrade-prompt {
padding-left: 1em;
padding-right: 1em;
text-align: center;
font-style: italic;
color: @primary;
}
body#pad-sub #posts, .atoms {
margin-top: 1.5em;
h3 {
margin-bottom: 0.25em;
&+ h4 {
margin-top: 0.25em;
margin-bottom: 0.5em;
&+ p {
margin-top: 0.5em;
}
}
.electron {
font-weight: normal;
margin-left: 0.5em;
}
}
h3, h4 {
a {
.transition-duration(0.2s);
-moz-transition-property: color;
-webkit-transition-property: color;
-o-transition-property: color;
transition-property: color;
}
}
h4 {
font-size: 0.9em;
font-weight: normal;
}
date, .electron {
margin-right: 0.5em;
}
.action {
font-size: 1em;
}
#more-posts p {
text-align: center;
font-size: 1.1em;
}
p {
font-size: 0.86em;
}
.error {
display: inline-block;
font-size: 0.8em;
font-style: italic;
color: @errUrgentCol;
strong {
font-style: normal;
}
}
.error + nav {
display: inline-block;
font-size: 0.8em;
margin-left: 1em;
a + a {
margin-left: 0.75em;
}
}
}
h2 {
a, time {
&+.action {
margin-left: 0.5em;
}
}
}
.action {
font-size: 0.7em;
font-weight: normal;
font-family: @serifFont;
&+ .action {
margin-left: 0.5em;
}
&.new-post {
font-weight: bold;
}
}
article.moved {
p {
font-size: 1.2em;
color: #999;
}
}
span.as {
.opacity(0.2);
font-weight: normal;
}
span.ras {
.opacity(0.6);
font-weight: normal;
}
header {
nav {
.username {
font-size: 2em;
font-weight: normal;
color: #555;
}
user-nav {
margin-left: 0;
& > a, .tabs > a {
&.selected {
cursor: default;
font-weight: bold;
&:hover {
text-decoration: none;
}
}
& + a {
margin-left: 2em;
}
}
a {
font-size: 1.2em;
font-family: @sansFont;
span {
font-size: 0.7em;
color: #999;
text-transform: uppercase;
margin-left: 0.5em;
margin-right: 0.5em;
}
&.title {
font-size: 1.6em;
font-family: @serifFont;
font-weight: bold;
}
}
nav > ul > li:first-child {
&> a {
display: inline-block;
}
img {
position: relative;
top: -0.5em;
right: 0.3em;
}
}
ul ul {
font-size: 0.8em;
a {
padding-top: 0.25em;
padding-bottom: 0.25em;
}
}
li {
line-height: 1.5;
}
}
&.tabs {
margin: 0 0 0 1em;
}
&+ nav.tabs {
margin: 0;
}
}
&.singleuser {
margin: 0.5em 0.25em;
nav#user-nav {
nav > ul > li:first-child {
img {
top: -0.75em;
}
}
}
}
.dash-nav {
font-weight: bold;
}
}
li#create-collection {
display: none;
h4 {
margin-top: 0px;
margin-bottom: 0px;
}
input[type=submit] {
margin-left: 0.5em;
}
}
#collection-options {
.option {
textarea {
font-size: 0.86em;
font-family: @monoFont;
}
.section > p.explain {
font-size: 0.8em;
}
}
}
.img-placeholder {
text-align: center;
img {
max-width: 100%;
}
}
dl {
&.admin-dl-horizontal {
dt {
font-weight: bolder;
width: 360px;
}
dd {
line-height: 1.5;
}
}
}
dt {
float: left;
clear: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
form {
dt, dd {
padding: 0.5rem 0;
}
dt {
line-height: 1.8;
}
dd {
font-size: 0.86em;
line-height: 2;
}
}
div.row {
display: flex;
align-items: center;
> div {
flex: 1;
}
}
@media all and (max-width: 450px) {
body#post {
header {
nav {
.xtra-feature {
display: none;
}
}
}
}
}
@media all and (min-width: 1280px) {
body#promo {
div.heading {
margin: 10em 0;
}
}
}
@media all and (min-width: 1600px) {
body#promo {
div.heading {
margin: 14em 0;
}
}
}
@media all and (max-width: 900px) {
.half.big {
padding: 0 !important;
width: 100% !important;
}
.third {
padding: 0 !important;
float: none;
width: 100% !important;
p.introduction {
font-size: 0.86em;
}
}
div.blurbs {
.fourth {
flex: 1 1 15em;
-webkit-flex: 1 1 15em;
}
}
.blurbs .third, .blurbs .half {
p, ul {
text-align: left;
}
}
.half-col, .big {
float: none;
text-align: center;
&+.half-col, &+.big {
margin-top: 4em !important;
margin-left: 0;
}
}
#beta, .content-container {
font-size: 1.15em;
}
}
@media all and (max-width: 600px) {
div.row {
flex-direction: column;
}
.half {
padding: 0 !important;
width: 100% !important;
}
.third {
width: 100% !important;
float: none;
}
body#promo {
div.heading {
margin: 6em 0;
}
h2 {
font-size: 1.6em;
}
.half-col a + a {
margin-left: 1em;
}
.half-col a.channel {
margin-left: auto !important;
margin-right: auto !important;
}
}
ul.add-integrations {
li {
display: list-item;
&+ li {
margin-left: 0;
}
}
}
}
@media all and (max-height: 500px) {
body#promo {
div.heading {
margin: 5em 0;
}
}
}
@media all and (max-height: 400px) {
body#promo {
div.heading {
margin: 0em 0;
}
}
}
/* Smartphones (portrait and landscape) ----------- */
@media only screen and (min-device-width : 320px) and (max-device-width : 480px) {
header {
.opacity(1);
}
}
/* Smartphones (portrait) ----------- */
@media only screen and (max-width : 320px) {
.content-container#pricing {
.half {
float: none;
width: 100%;
}
}
header {
.opacity(1);
}
}
/* iPads (portrait and landscape) ----------- */
@media only screen and (min-device-width : 768px) and (max-device-width : 1024px) {
header {
.opacity(1);
}
}
@media (pointer: coarse) {
body footer nav a:not(.pubd) {
padding: 0.8em 1em;
margin-left: 0;
margin-top: 0;
}
}
@media print {
h1 {
page-break-before: always;
}
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
}
table, figure {
page-break-inside: avoid;
}
header, footer {
display: none;
}
article#post-body {
margin-top: 2em;
margin-left: 0;
margin-right: 0;
}
hr {
border: 1px solid #ccc;
}
}
.code-block {
padding: 0;
max-width: 100%;
margin: 0;
background: #f8f8f8;
border: 1px solid #ccc;
padding: 0.375em 0.625em;
font-size: 0.86em;
.rounded(.25em);
}
pre.code-block {
overflow-x: auto;
}
diff --git a/postrender.go b/postrender.go
index 31a3163..af715be 100644
--- a/postrender.go
+++ b/postrender.go
@@ -1,226 +1,228 @@
/*
* 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 (
"fmt"
"github.com/microcosm-cc/bluemonday"
stripmd "github.com/writeas/go-strip-markdown"
"github.com/writeas/saturday"
"github.com/writeas/web-core/stringmanip"
"github.com/writeas/writefreely/parse"
"html"
"html/template"
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
var (
blockReg = regexp.MustCompile("<(ul|ol|blockquote)>\n")
endBlockReg = regexp.MustCompile("([a-z]+)>\n(ul|ol|blockquote)>")
youtubeReg = regexp.MustCompile("(https?://www.youtube.com/embed/[a-zA-Z0-9\\-_]+)(\\?[^\t\n\f\r \"']+)?")
titleElementReg = regexp.MustCompile("?h[1-6]>")
hashtagReg = regexp.MustCompile(`{{\[\[\|\|([^|]+)\|\|\]\]}}`)
markeddownReg = regexp.MustCompile("(.+)
")
)
func (p *Post) formatContent(c *Collection, isOwner bool) {
baseURL := c.CanonicalURL()
if !isSingleUser {
baseURL = "/" + c.Alias + "/"
}
p.HTMLTitle = template.HTML(applyBasicMarkdown([]byte(p.Title.String)))
p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), baseURL))
if exc := strings.Index(string(p.Content), ""); exc > -1 {
p.HTMLExcerpt = template.HTML(applyMarkdown([]byte(p.Content[:exc]), baseURL))
}
}
func (p *PublicPost) formatContent(isOwner bool) {
p.Post.formatContent(&p.Collection.Collection, isOwner)
}
func applyMarkdown(data []byte, baseURL string) string {
return applyMarkdownSpecial(data, false, baseURL)
}
func applyMarkdownSpecial(data []byte, skipNoFollow bool, baseURL string) string {
mdExtensions := 0 |
blackfriday.EXTENSION_TABLES |
blackfriday.EXTENSION_FENCED_CODE |
blackfriday.EXTENSION_AUTOLINK |
blackfriday.EXTENSION_STRIKETHROUGH |
blackfriday.EXTENSION_SPACE_HEADERS |
blackfriday.EXTENSION_AUTO_HEADER_IDS
htmlFlags := 0 |
blackfriday.HTML_USE_SMARTYPANTS |
blackfriday.HTML_SMARTYPANTS_DASHES
if baseURL != "" {
htmlFlags |= blackfriday.HTML_HASHTAGS
}
// Generate Markdown
md := blackfriday.Markdown([]byte(data), blackfriday.HtmlRenderer(htmlFlags, "", ""), mdExtensions)
if baseURL != "" {
// Replace special text generated by Markdown parser
md = []byte(hashtagReg.ReplaceAll(md, []byte("# $1 ")))
}
// Strip out bad HTML
policy := getSanitizationPolicy()
policy.RequireNoFollowOnLinks(!skipNoFollow)
outHTML := string(policy.SanitizeBytes(md))
// Strip newlines on certain block elements that render with them
outHTML = blockReg.ReplaceAllString(outHTML, "<$1>")
outHTML = endBlockReg.ReplaceAllString(outHTML, "$1>$2>")
// Remove all query parameters on YouTube embed links
// TODO: make this more specific. Taking the nuclear approach here to strip ?autoplay=1
outHTML = youtubeReg.ReplaceAllString(outHTML, "$1")
return outHTML
}
func applyBasicMarkdown(data []byte) string {
mdExtensions := 0 |
blackfriday.EXTENSION_STRIKETHROUGH |
blackfriday.EXTENSION_SPACE_HEADERS |
blackfriday.EXTENSION_HEADER_IDS
htmlFlags := 0 |
blackfriday.HTML_SKIP_HTML |
blackfriday.HTML_USE_SMARTYPANTS |
blackfriday.HTML_SMARTYPANTS_DASHES
// Generate Markdown
md := blackfriday.Markdown([]byte(data), blackfriday.HtmlRenderer(htmlFlags, "", ""), mdExtensions)
// Strip out bad HTML
policy := bluemonday.UGCPolicy()
policy.AllowAttrs("class", "id").Globally()
outHTML := string(policy.SanitizeBytes(md))
outHTML = markeddownReg.ReplaceAllString(outHTML, "$1")
outHTML = strings.TrimRightFunc(outHTML, unicode.IsSpace)
return outHTML
}
func postTitle(content, friendlyId string) string {
const maxTitleLen = 80
// Strip HTML tags with bluemonday's StrictPolicy, then unescape the HTML
// entities added in by sanitizing the content.
content = html.UnescapeString(bluemonday.StrictPolicy().Sanitize(content))
content = strings.TrimLeftFunc(stripmd.Strip(content), unicode.IsSpace)
eol := strings.IndexRune(content, '\n')
blankLine := strings.Index(content, "\n\n")
if blankLine != -1 && blankLine <= eol && blankLine <= assumedTitleLen {
return strings.TrimSpace(content[:blankLine])
} else if utf8.RuneCountInString(content) <= maxTitleLen {
return content
}
return friendlyId
}
// TODO: fix duplicated code from postTitle. postTitle is a widely used func we
// don't have time to investigate right now.
func friendlyPostTitle(content, friendlyId string) string {
const maxTitleLen = 80
// Strip HTML tags with bluemonday's StrictPolicy, then unescape the HTML
// entities added in by sanitizing the content.
content = html.UnescapeString(bluemonday.StrictPolicy().Sanitize(content))
content = strings.TrimLeftFunc(stripmd.Strip(content), unicode.IsSpace)
eol := strings.IndexRune(content, '\n')
blankLine := strings.Index(content, "\n\n")
if blankLine != -1 && blankLine <= eol && blankLine <= assumedTitleLen {
return strings.TrimSpace(content[:blankLine])
} else if eol == -1 && utf8.RuneCountInString(content) <= maxTitleLen {
return content
}
title, truncd := parse.TruncToWord(parse.PostLede(content, true), maxTitleLen)
if truncd {
title += "..."
}
return title
}
func getSanitizationPolicy() *bluemonday.Policy {
policy := bluemonday.UGCPolicy()
- policy.AllowAttrs("src", "style").OnElements("iframe", "video")
+ policy.AllowAttrs("src", "style").OnElements("iframe", "video", "audio")
+ policy.AllowAttrs("src", "type").OnElements("source")
policy.AllowAttrs("frameborder", "width", "height").Matching(bluemonday.Integer).OnElements("iframe")
policy.AllowAttrs("allowfullscreen").OnElements("iframe")
policy.AllowAttrs("controls", "loop", "muted", "autoplay").OnElements("video")
+ policy.AllowAttrs("controls", "loop", "muted", "autoplay", "preload").OnElements("audio")
policy.AllowAttrs("target").OnElements("a")
policy.AllowAttrs("style", "class", "id").Globally()
policy.AllowURLSchemes("http", "https", "mailto", "xmpp")
return policy
}
func sanitizePost(content string) string {
return strings.Replace(content, "<", "<", -1)
}
// postDescription generates a description based on the given post content,
// title, and post ID. This doesn't consider a V2 post field, `title` when
// choosing what to generate. In case a post has a title, this function will
// fail, and logic should instead be implemented to skip this when there's no
// title, like so:
// var desc string
// if title == "" {
// desc = postDescription(content, title, friendlyId)
// } else {
// desc = shortPostDescription(content)
// }
func postDescription(content, title, friendlyId string) string {
maxLen := 140
if content == "" {
content = "WriteFreely is a painless, simple, federated blogging platform."
} else {
fmtStr := "%s"
truncation := 0
if utf8.RuneCountInString(content) > maxLen {
// Post is longer than the max description, so let's show a better description
fmtStr = "%s..."
truncation = 3
}
if title == friendlyId {
// No specific title was found; simply truncate the post, starting at the beginning
content = fmt.Sprintf(fmtStr, strings.Replace(stringmanip.Substring(content, 0, maxLen-truncation), "\n", " ", -1))
} else {
// There was a title, so return a real description
blankLine := strings.Index(content, "\n\n")
if blankLine < 0 {
blankLine = 0
}
truncd := stringmanip.Substring(content, blankLine, blankLine+maxLen-truncation)
contentNoNL := strings.Replace(truncd, "\n", " ", -1)
content = strings.TrimSpace(fmt.Sprintf(fmtStr, contentNoNL))
}
}
return content
}
func shortPostDescription(content string) string {
maxLen := 140
fmtStr := "%s"
truncation := 0
if utf8.RuneCountInString(content) > maxLen {
// Post is longer than the max description, so let's show a better description
fmtStr = "%s..."
truncation = 3
}
return strings.TrimSpace(fmt.Sprintf(fmtStr, strings.Replace(stringmanip.Substring(content, 0, maxLen-truncation), "\n", " ", -1)))
}
diff --git a/posts.go b/posts.go
index 07902ce..2f3606f 100644
--- a/posts.go
+++ b/posts.go
@@ -1,1421 +1,1454 @@
/*
* Copyright © 2018-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 writefreely
import (
"database/sql"
"encoding/json"
"fmt"
"html/template"
"net/http"
"regexp"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/guregu/null"
"github.com/guregu/null/zero"
"github.com/kylemcc/twitter-text-go/extract"
"github.com/microcosm-cc/bluemonday"
stripmd "github.com/writeas/go-strip-markdown"
"github.com/writeas/impart"
"github.com/writeas/monday"
"github.com/writeas/slug"
"github.com/writeas/web-core/activitystreams"
"github.com/writeas/web-core/bots"
"github.com/writeas/web-core/converter"
"github.com/writeas/web-core/i18n"
"github.com/writeas/web-core/log"
"github.com/writeas/web-core/tags"
"github.com/writeas/writefreely/page"
"github.com/writeas/writefreely/parse"
)
const (
// Post ID length bounds
minIDLen = 10
maxIDLen = 10
userPostIDLen = 10
postIDLen = 10
postMetaDateFormat = "2006-01-02 15:04:05"
)
type (
AnonymousPost struct {
ID string
Content string
HTMLContent template.HTML
Font string
Language string
Direction string
Title string
GenTitle string
Description string
Author string
Views int64
IsPlainText bool
IsCode bool
IsLinkable bool
}
AuthenticatedPost struct {
ID string `json:"id" schema:"id"`
Web bool `json:"web" schema:"web"`
*SubmittedPost
}
// SubmittedPost represents a post supplied by a client for publishing or
// updating. Since Title and Content can be updated to "", they are
// pointers that can be easily tested to detect changes.
SubmittedPost struct {
Slug *string `json:"slug" schema:"slug"`
Title *string `json:"title" schema:"title"`
Content *string `json:"body" schema:"body"`
Font string `json:"font" schema:"font"`
IsRTL converter.NullJSONBool `json:"rtl" schema:"rtl"`
Language converter.NullJSONString `json:"lang" schema:"lang"`
Created *string `json:"created" schema:"created"`
}
// Post represents a post as found in the database.
Post struct {
ID string `db:"id" json:"id"`
Slug null.String `db:"slug" json:"slug,omitempty"`
Font string `db:"text_appearance" json:"appearance"`
Language zero.String `db:"language" json:"language"`
RTL zero.Bool `db:"rtl" json:"rtl"`
Privacy int64 `db:"privacy" json:"-"`
OwnerID null.Int `db:"owner_id" json:"-"`
CollectionID null.Int `db:"collection_id" json:"-"`
PinnedPosition null.Int `db:"pinned_position" json:"-"`
Created time.Time `db:"created" json:"created"`
Updated time.Time `db:"updated" json:"updated"`
ViewCount int64 `db:"view_count" json:"-"`
Title zero.String `db:"title" json:"title"`
HTMLTitle template.HTML `db:"title" json:"-"`
Content string `db:"content" json:"body"`
HTMLContent template.HTML `db:"content" json:"-"`
HTMLExcerpt template.HTML `db:"content" json:"-"`
Tags []string `json:"tags"`
Images []string `json:"images,omitempty"`
OwnerName string `json:"owner,omitempty"`
}
// PublicPost holds properties for a publicly returned post, i.e. a post in
// a context where the viewer may not be the owner. As such, sensitive
// metadata for the post is hidden and properties supporting the display of
// the post are added.
PublicPost struct {
*Post
IsSubdomain bool `json:"-"`
IsTopLevel bool `json:"-"`
DisplayDate string `json:"-"`
Views int64 `json:"views"`
Owner *PublicUser `json:"-"`
IsOwner bool `json:"-"`
Collection *CollectionObj `json:"collection,omitempty"`
}
RawPost struct {
Id, Slug string
Title string
Content string
Views int64
Font string
Created time.Time
IsRTL sql.NullBool
Language sql.NullString
OwnerID int64
CollectionID sql.NullInt64
Found bool
Gone bool
}
AnonymousAuthPost struct {
ID string `json:"id"`
Token string `json:"token"`
}
ClaimPostRequest struct {
*AnonymousAuthPost
CollectionAlias string `json:"collection"`
CreateCollection bool `json:"create_collection"`
// Generated properties
Slug string `json:"-"`
}
ClaimPostResult struct {
ID string `json:"id,omitempty"`
Code int `json:"code,omitempty"`
ErrorMessage string `json:"error_msg,omitempty"`
Post *PublicPost `json:"post,omitempty"`
}
)
func (p *Post) Direction() string {
if p.RTL.Valid {
if p.RTL.Bool {
return "rtl"
}
return "ltr"
}
return "auto"
}
// DisplayTitle dynamically generates a title from the Post's contents if it
// doesn't already have an explicit title.
func (p *Post) DisplayTitle() string {
if p.Title.String != "" {
return p.Title.String
}
t := friendlyPostTitle(p.Content, p.ID)
return t
}
// PlainDisplayTitle dynamically generates a title from the Post's contents if it
// doesn't already have an explicit title.
func (p *Post) PlainDisplayTitle() string {
if t := stripmd.Strip(p.DisplayTitle()); t != "" {
return t
}
return p.ID
}
// FormattedDisplayTitle dynamically generates a title from the Post's contents if it
// doesn't already have an explicit title.
func (p *Post) FormattedDisplayTitle() template.HTML {
if p.HTMLTitle != "" {
return p.HTMLTitle
}
return template.HTML(p.DisplayTitle())
}
// Summary gives a shortened summary of the post based on the post's title,
// especially for display in a longer list of posts. It extracts a summary for
// posts in the Title\n\nBody format, returning nothing if the entire was short
// enough that the extracted title == extracted summary.
func (p Post) Summary() string {
if p.Content == "" {
return ""
}
// Strip out HTML
p.Content = bluemonday.StrictPolicy().Sanitize(p.Content)
// and Markdown
p.Content = stripmd.Strip(p.Content)
title := p.Title.String
var desc string
if title == "" {
// No title, so generate one
title = friendlyPostTitle(p.Content, p.ID)
desc = postDescription(p.Content, title, p.ID)
if desc == title {
return ""
}
return desc
}
return shortPostDescription(p.Content)
}
// Excerpt shows any text that comes before a (more) tag.
// TODO: use HTMLExcerpt in templates instead of this method
func (p *Post) Excerpt() template.HTML {
return p.HTMLExcerpt
}
func (p *Post) CreatedDate() string {
return p.Created.Format("2006-01-02")
}
func (p *Post) Created8601() string {
return p.Created.Format("2006-01-02T15:04:05Z")
}
func (p *Post) IsScheduled() bool {
return p.Created.After(time.Now())
}
func (p *Post) HasTag(tag string) bool {
// Regexp looks for tag and has a non-capturing group at the end looking
// for the end of the word.
// Assisted by: https://stackoverflow.com/a/35192941/1549194
hasTag, _ := regexp.MatchString("#"+tag+`(?:[[:punct:]]|\s|\z)`, p.Content)
return hasTag
}
func (p *Post) HasTitleLink() bool {
if p.Title.String == "" {
return false
}
hasLink, _ := regexp.MatchString(`([^!]+|^)\[.+\]\(.+\)`, p.Title.String)
return hasLink
}
func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
friendlyID := vars["post"]
// NOTE: until this is done better, be sure to keep this in parity with
// isRaw() and viewCollectionPost()
isJSON := strings.HasSuffix(friendlyID, ".json")
isXML := strings.HasSuffix(friendlyID, ".xml")
isCSS := strings.HasSuffix(friendlyID, ".css")
isMarkdown := strings.HasSuffix(friendlyID, ".md")
isRaw := strings.HasSuffix(friendlyID, ".txt") || isJSON || isXML || isCSS || isMarkdown
// Display reserved page if that is requested resource
if t, ok := pages[r.URL.Path[1:]+".tmpl"]; ok {
return handleTemplatedPage(app, w, r, t)
} else if (strings.Contains(r.URL.Path, ".") && !isRaw && !isMarkdown) || r.URL.Path == "/robots.txt" || r.URL.Path == "/manifest.json" {
// Serve static file
app.shttp.ServeHTTP(w, r)
return nil
}
// Display collection if this is a collection
c, _ := app.db.GetCollection(friendlyID)
if c != nil {
return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s/", friendlyID)}
}
// Normalize the URL, redirecting user to consistent post URL
if friendlyID != strings.ToLower(friendlyID) {
return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s", strings.ToLower(friendlyID))}
}
ext := ""
if isRaw {
parts := strings.Split(friendlyID, ".")
friendlyID = parts[0]
if len(parts) > 1 {
ext = "." + parts[1]
}
}
var ownerID sql.NullInt64
var title string
var content string
var font string
var language []byte
var rtl []byte
var views int64
var post *AnonymousPost
var found bool
var gone bool
fixedID := slug.Make(friendlyID)
if fixedID != friendlyID {
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/%s%s", fixedID, ext)}
}
err := app.db.QueryRow(fmt.Sprintf("SELECT owner_id, title, content, text_appearance, view_count, language, rtl FROM posts WHERE id = ?"), friendlyID).Scan(&ownerID, &title, &content, &font, &views, &language, &rtl)
switch {
case err == sql.ErrNoRows:
found = false
// Output the error in the correct format
if isJSON {
content = "{\"error\": \"Post not found.\"}"
} else if isRaw {
content = "Post not found."
} else {
return ErrPostNotFound
}
case err != nil:
found = false
log.Error("Post loading err: %s\n", err)
return ErrInternalGeneral
default:
found = true
var d string
if len(rtl) == 0 {
d = "auto"
} else if rtl[0] == 49 {
// TODO: find a cleaner way to get this (possibly NULL) value
d = "rtl"
} else {
d = "ltr"
}
generatedTitle := friendlyPostTitle(content, friendlyID)
sanitizedContent := content
if font != "code" {
sanitizedContent = template.HTMLEscapeString(content)
}
var desc string
if title == "" {
desc = postDescription(content, title, friendlyID)
} else {
desc = shortPostDescription(content)
}
post = &AnonymousPost{
ID: friendlyID,
Content: sanitizedContent,
Title: title,
GenTitle: generatedTitle,
Description: desc,
Author: "",
Font: font,
IsPlainText: isRaw,
IsCode: font == "code",
IsLinkable: font != "code",
Views: views,
Language: string(language),
Direction: d,
}
if !isRaw {
post.HTMLContent = template.HTML(applyMarkdown([]byte(content), ""))
}
}
// Check if post has been unpublished
if content == "" {
gone = true
if isJSON {
content = "{\"error\": \"Post was unpublished.\"}"
} else if isCSS {
content = ""
} else if isRaw {
content = "Post was unpublished."
} else {
return ErrPostUnpublished
}
}
var u = &User{}
if isRaw {
contentType := "text/plain"
if isJSON {
contentType = "application/json"
} else if isCSS {
contentType = "text/css"
} else if isXML {
contentType = "application/xml"
} else if isMarkdown {
contentType = "text/markdown"
}
w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType))
if isMarkdown && post.Title != "" {
fmt.Fprintf(w, "%s\n", post.Title)
for i := 1; i <= len(post.Title); i++ {
fmt.Fprintf(w, "=")
}
fmt.Fprintf(w, "\n\n")
}
fmt.Fprint(w, content)
if !found {
return ErrPostNotFound
} else if gone {
return ErrPostUnpublished
}
} else {
var err error
page := struct {
*AnonymousPost
page.StaticPage
Username string
IsOwner bool
SiteURL string
}{
AnonymousPost: post,
StaticPage: pageForReq(app, r),
SiteURL: app.cfg.App.Host,
}
if u = getUserSession(app, r); u != nil {
page.Username = u.Username
page.IsOwner = ownerID.Valid && ownerID.Int64 == u.ID
}
err = templates["post"].ExecuteTemplate(w, "post", page)
if err != nil {
log.Error("Post template execute error: %v", err)
}
}
go func() {
if u != nil && ownerID.Valid && ownerID.Int64 == u.ID {
// Post is owned by someone; skip view increment since that person is viewing this post.
return
}
// Update stats for non-raw post views
if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) {
_, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE id = ?", friendlyID)
if err != nil {
log.Error("Unable to update posts count: %v", err)
}
}
}()
return nil
}
// API v2 funcs
// newPost creates a new post with or without an owning Collection.
//
// Endpoints:
// /posts
// /posts?collection={alias}
// ? /collections/{alias}/posts
func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type"))
vars := mux.Vars(r)
collAlias := vars["alias"]
if collAlias == "" {
collAlias = r.FormValue("collection")
}
accessToken := r.Header.Get("Authorization")
if accessToken == "" {
// TODO: remove this
accessToken = r.FormValue("access_token")
}
// FIXME: determine web submission with Content-Type header
var u *User
var userID int64 = -1
var username string
if accessToken == "" {
u = getUserSession(app, r)
if u != nil {
userID = u.ID
username = u.Username
}
} else {
userID = app.db.GetUserID(accessToken)
}
if userID == -1 {
return ErrNotLoggedIn
}
if accessToken == "" && u == nil && collAlias != "" {
return impart.HTTPError{http.StatusBadRequest, "Parameter `access_token` required."}
}
// Get post data
var p *SubmittedPost
if reqJSON {
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&p)
if err != nil {
log.Error("Couldn't parse new post JSON request: %v\n", err)
return ErrBadJSON
}
if p.Title == nil {
t := ""
p.Title = &t
}
if strings.TrimSpace(*(p.Content)) == "" {
return ErrNoPublishableContent
}
} else {
post := r.FormValue("body")
appearance := r.FormValue("font")
title := r.FormValue("title")
rtlValue := r.FormValue("rtl")
langValue := r.FormValue("lang")
if strings.TrimSpace(post) == "" {
return ErrNoPublishableContent
}
var isRTL, rtlValid bool
if rtlValue == "auto" && langValue != "" {
isRTL = i18n.LangIsRTL(langValue)
rtlValid = true
} else {
isRTL = rtlValue == "true"
rtlValid = rtlValue != "" && langValue != ""
}
// Create a new post
p = &SubmittedPost{
Title: &title,
Content: &post,
Font: appearance,
IsRTL: converter.NullJSONBool{sql.NullBool{Bool: isRTL, Valid: rtlValid}},
Language: converter.NullJSONString{sql.NullString{String: langValue, Valid: langValue != ""}},
}
}
if !p.isFontValid() {
p.Font = "norm"
}
var newPost *PublicPost = &PublicPost{}
var coll *Collection
var err error
if accessToken != "" {
- newPost, err = app.db.CreateOwnedPost(p, accessToken, collAlias)
+ newPost, err = app.db.CreateOwnedPost(p, accessToken, collAlias, app.cfg.App.Host)
} else {
//return ErrNotLoggedIn
// TODO: verify user is logged in
var collID int64
if collAlias != "" {
coll, err = app.db.GetCollection(collAlias)
if err != nil {
return err
}
coll.hostName = app.cfg.App.Host
if coll.OwnerID != u.ID {
return ErrForbiddenCollection
}
collID = coll.ID
}
// TODO: return PublicPost from createPost
newPost.Post, err = app.db.CreatePost(userID, collID, p)
}
if err != nil {
return err
}
if coll != nil {
coll.ForPublic()
newPost.Collection = &CollectionObj{Collection: *coll}
}
newPost.extractData()
newPost.OwnerName = username
// Write success now
response := impart.WriteSuccess(w, newPost, http.StatusCreated)
if newPost.Collection != nil && !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) {
go federatePost(app, newPost, newPost.Collection.ID, false)
}
return response
}
func existingPost(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type"))
vars := mux.Vars(r)
postID := vars["post"]
p := AuthenticatedPost{ID: postID}
var err error
if reqJSON {
// Decode JSON request
decoder := json.NewDecoder(r.Body)
err = decoder.Decode(&p)
if err != nil {
log.Error("Couldn't parse post update JSON request: %v\n", err)
return ErrBadJSON
}
} else {
err = r.ParseForm()
if err != nil {
log.Error("Couldn't parse post update form request: %v\n", err)
return ErrBadFormData
}
// Can't decode to a nil SubmittedPost property, so create instance now
p.SubmittedPost = &SubmittedPost{}
err = app.formDecoder.Decode(&p, r.PostForm)
if err != nil {
log.Error("Couldn't decode post update form request: %v\n", err)
return ErrBadFormData
}
}
if p.Web {
p.IsRTL.Valid = true
}
if p.SubmittedPost == nil {
return ErrPostNoUpdatableVals
}
// Ensure an access token was given
accessToken := r.Header.Get("Authorization")
// Get user's cookie session if there's no token
var u *User
//var username string
if accessToken == "" {
u = getUserSession(app, r)
if u != nil {
//username = u.Username
}
}
if u == nil && accessToken == "" {
return ErrNoAccessToken
}
// Get user ID from current session or given access token, if one was given.
var userID int64
if u != nil {
userID = u.ID
} else if accessToken != "" {
userID, err = AuthenticateUser(app.db, accessToken)
if err != nil {
return err
}
}
// Modify post struct
p.ID = postID
err = app.db.UpdateOwnedPost(&p, userID)
if err != nil {
if reqJSON {
return err
}
if err, ok := err.(impart.HTTPError); ok {
addSessionFlash(app, w, r, err.Message, nil)
} else {
addSessionFlash(app, w, r, err.Error(), nil)
}
}
var pRes *PublicPost
pRes, err = app.db.GetPost(p.ID, 0)
if reqJSON {
if err != nil {
return err
}
pRes.extractData()
}
if pRes.CollectionID.Valid {
coll, err := app.db.GetCollectionBy("id = ?", pRes.CollectionID.Int64)
if err == nil && !app.cfg.App.Private && app.cfg.App.Federation {
coll.hostName = app.cfg.App.Host
pRes.Collection = &CollectionObj{Collection: *coll}
go federatePost(app, pRes, pRes.Collection.ID, true)
}
}
// Write success now
if reqJSON {
return impart.WriteSuccess(w, pRes, http.StatusOK)
}
addSessionFlash(app, w, r, "Changes saved.", nil)
collectionAlias := vars["alias"]
redirect := "/" + postID + "/meta"
if collectionAlias != "" {
collPre := "/" + collectionAlias
if app.cfg.App.SingleUser {
collPre = ""
}
redirect = collPre + "/" + pRes.Slug.String + "/edit/meta"
} else {
if app.cfg.App.SingleUser {
redirect = "/d" + redirect
}
}
w.Header().Set("Location", redirect)
w.WriteHeader(http.StatusFound)
return nil
}
func deletePost(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
friendlyID := vars["post"]
editToken := r.FormValue("token")
var ownerID int64
var u *User
accessToken := r.Header.Get("Authorization")
if accessToken == "" && editToken == "" {
u = getUserSession(app, r)
if u == nil {
return ErrNoAccessToken
}
}
var res sql.Result
var t *sql.Tx
var err error
var collID sql.NullInt64
var coll *Collection
var pp *PublicPost
if editToken != "" {
// TODO: SELECT owner_id, as well, and return appropriate error if NULL instead of running two queries
var dummy int64
err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ?", friendlyID).Scan(&dummy)
switch {
case err == sql.ErrNoRows:
return impart.HTTPError{http.StatusNotFound, "Post not found."}
}
err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ? AND owner_id IS NULL", friendlyID).Scan(&dummy)
switch {
case err == sql.ErrNoRows:
// Post already has an owner. This could provide a bad experience
// for the user, but it's more important to ensure data isn't lost
// unexpectedly. So prevent deletion via token.
return impart.HTTPError{http.StatusConflict, "This post belongs to some user (hopefully yours). Please log in and delete it from that user's account."}
}
res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND modify_token = ? AND owner_id IS NULL", friendlyID, editToken)
} else if accessToken != "" || u != nil {
// Caller provided some way to authenticate; assume caller expects the
// post to be deleted based on a specific post owner, thus we should
// return corresponding errors.
if accessToken != "" {
ownerID = app.db.GetUserID(accessToken)
if ownerID == -1 {
return ErrBadAccessToken
}
} else {
ownerID = u.ID
}
// TODO: don't make two queries
var realOwnerID sql.NullInt64
err = app.db.QueryRow("SELECT collection_id, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&collID, &realOwnerID)
if err != nil {
return err
}
if !collID.Valid {
// There's no collection; simply delete the post
res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID)
} else {
// Post belongs to a collection; do any additional clean up
coll, err = app.db.GetCollectionBy("id = ?", collID.Int64)
if err != nil {
log.Error("Unable to get collection: %v", err)
return err
}
if app.cfg.App.Federation {
// First fetch full post for federation
pp, err = app.db.GetOwnedPost(friendlyID, ownerID)
if err != nil {
log.Error("Unable to get owned post: %v", err)
return err
}
collObj := &CollectionObj{Collection: *coll}
pp.Collection = collObj
}
t, err = app.db.Begin()
if err != nil {
log.Error("No begin: %v", err)
return err
}
res, err = t.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID)
}
} else {
return impart.HTTPError{http.StatusBadRequest, "No authenticated user or post token given."}
}
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
if t != nil {
t.Rollback()
log.Error("Rows affected err! Rolling back")
}
return err
} else if affected == 0 {
if t != nil {
t.Rollback()
log.Error("No rows affected! Rolling back")
}
return impart.HTTPError{http.StatusForbidden, "Post not found, or you're not the owner."}
}
if t != nil {
t.Commit()
}
if coll != nil && !app.cfg.App.Private && app.cfg.App.Federation {
go deleteFederatedPost(app, pp, collID.Int64)
}
return impart.HTTPError{Status: http.StatusNoContent}
}
// addPost associates a post with the authenticated user.
func addPost(app *App, w http.ResponseWriter, r *http.Request) error {
var ownerID int64
// Authenticate user
at := r.Header.Get("Authorization")
if at != "" {
ownerID = app.db.GetUserID(at)
if ownerID == -1 {
return ErrBadAccessToken
}
} else {
u := getUserSession(app, r)
if u == nil {
return ErrNotLoggedIn
}
ownerID = u.ID
}
// Parse claimed posts in format:
// [{"id": "...", "token": "..."}]
var claims *[]ClaimPostRequest
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&claims)
if err != nil {
return ErrBadJSONArray
}
vars := mux.Vars(r)
collAlias := vars["alias"]
// Update all given posts
- res, err := app.db.ClaimPosts(ownerID, collAlias, claims)
+ res, err := app.db.ClaimPosts(app.cfg, ownerID, collAlias, claims)
if err != nil {
return err
}
if !app.cfg.App.Private && app.cfg.App.Federation {
for _, pRes := range *res {
if pRes.Code != http.StatusOK {
continue
}
if !pRes.Post.Created.After(time.Now()) {
pRes.Post.Collection.hostName = app.cfg.App.Host
go federatePost(app, pRes.Post, pRes.Post.Collection.ID, false)
}
}
}
return impart.WriteSuccess(w, res, http.StatusOK)
}
func dispersePost(app *App, w http.ResponseWriter, r *http.Request) error {
var ownerID int64
// Authenticate user
at := r.Header.Get("Authorization")
if at != "" {
ownerID = app.db.GetUserID(at)
if ownerID == -1 {
return ErrBadAccessToken
}
} else {
u := getUserSession(app, r)
if u == nil {
return ErrNotLoggedIn
}
ownerID = u.ID
}
// Parse posts in format:
// ["..."]
var postIDs []string
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&postIDs)
if err != nil {
return ErrBadJSONArray
}
// Update all given posts
res, err := app.db.DispersePosts(ownerID, postIDs)
if err != nil {
return err
}
return impart.WriteSuccess(w, res, http.StatusOK)
}
type (
PinPostResult struct {
ID string `json:"id,omitempty"`
Code int `json:"code,omitempty"`
ErrorMessage string `json:"error_msg,omitempty"`
}
)
// pinPost pins a post to a blog
func pinPost(app *App, w http.ResponseWriter, r *http.Request) error {
var userID int64
// Authenticate user
at := r.Header.Get("Authorization")
if at != "" {
userID = app.db.GetUserID(at)
if userID == -1 {
return ErrBadAccessToken
}
} else {
u := getUserSession(app, r)
if u == nil {
return ErrNotLoggedIn
}
userID = u.ID
}
// Parse request
var posts []struct {
ID string `json:"id"`
Position int64 `json:"position"`
}
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&posts)
if err != nil {
return ErrBadJSONArray
}
// Validate data
vars := mux.Vars(r)
collAlias := vars["alias"]
coll, err := app.db.GetCollection(collAlias)
if err != nil {
return err
}
if coll.OwnerID != userID {
return ErrForbiddenCollection
}
// Do (un)pinning
isPinning := r.URL.Path[strings.LastIndex(r.URL.Path, "/"):] == "/pin"
res := []PinPostResult{}
for _, p := range posts {
err = app.db.UpdatePostPinState(isPinning, p.ID, coll.ID, userID, p.Position)
ppr := PinPostResult{ID: p.ID}
if err != nil {
ppr.Code = http.StatusInternalServerError
// TODO: set error messsage
} else {
ppr.Code = http.StatusOK
}
res = append(res, ppr)
}
return impart.WriteSuccess(w, res, http.StatusOK)
}
func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
var collID int64
var coll *Collection
var err error
vars := mux.Vars(r)
if collAlias := vars["alias"]; collAlias != "" {
// Fetch collection information, since an alias is provided
coll, err = app.db.GetCollection(collAlias)
if err != nil {
return err
}
coll.hostName = app.cfg.App.Host
_, err = apiCheckCollectionPermissions(app, r, coll)
if err != nil {
return err
}
collID = coll.ID
}
p, err := app.db.GetPost(vars["post"], collID)
if err != nil {
return err
}
p.extractData()
accept := r.Header.Get("Accept")
if strings.Contains(accept, "application/activity+json") {
// Fetch information about the collection this belongs to
if coll == nil && p.CollectionID.Valid {
coll, err = app.db.GetCollectionByID(p.CollectionID.Int64)
if err != nil {
return err
}
}
if coll == nil {
// This is a draft post; 404 for now
// TODO: return ActivityObject
return impart.HTTPError{http.StatusNotFound, ""}
}
p.Collection = &CollectionObj{Collection: *coll}
po := p.ActivityObject()
po.Context = []interface{}{activitystreams.Namespace}
return impart.RenderActivityJSON(w, po, http.StatusOK)
}
return impart.WriteSuccess(w, p, http.StatusOK)
}
func fetchPostProperty(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
p, err := app.db.GetPostProperty(vars["post"], 0, vars["property"])
if err != nil {
return err
}
return impart.WriteSuccess(w, p, http.StatusOK)
}
func (p *Post) processPost() PublicPost {
res := &PublicPost{Post: p, Views: 0}
res.Views = p.ViewCount
// TODO: move to own function
loc := monday.FuzzyLocale(p.Language.String)
res.DisplayDate = monday.Format(p.Created, monday.LongFormatsByLocale[loc], loc)
return *res
}
func (p *PublicPost) CanonicalURL() string {
if p.Collection == nil || p.Collection.Alias == "" {
return p.Collection.hostName + "/" + p.ID
}
return p.Collection.CanonicalURL() + p.Slug.String
}
func (p *PublicPost) ActivityObject() *activitystreams.Object {
o := activitystreams.NewArticleObject()
o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID
o.Published = p.Created
o.URL = p.CanonicalURL()
o.AttributedTo = p.Collection.FederatedAccount()
o.CC = []string{
p.Collection.FederatedAccount() + "/followers",
}
o.Name = p.DisplayTitle()
if p.HTMLContent == template.HTML("") {
p.formatContent(false)
}
o.Content = string(p.HTMLContent)
if p.Language.Valid {
o.ContentMap = map[string]string{
p.Language.String: string(p.HTMLContent),
}
}
if len(p.Tags) == 0 {
o.Tag = []activitystreams.Tag{}
} else {
var tagBaseURL string
if isSingleUser {
tagBaseURL = p.Collection.CanonicalURL() + "tag:"
} else {
tagBaseURL = fmt.Sprintf("%s/%s/tag:", p.Collection.hostName, p.Collection.Alias)
}
for _, t := range p.Tags {
o.Tag = append(o.Tag, activitystreams.Tag{
Type: activitystreams.TagHashtag,
HRef: tagBaseURL + t,
Name: "#" + t,
})
}
}
return o
}
// TODO: merge this into getSlugFromPost or phase it out
func getSlug(title, lang string) string {
return getSlugFromPost("", title, lang)
}
func getSlugFromPost(title, body, lang string) string {
if title == "" {
title = postTitle(body, body)
}
title = parse.PostLede(title, false)
// Truncate lede if needed
title, _ = parse.TruncToWord(title, 80)
var s string
if lang != "" && len(lang) == 2 {
s = slug.MakeLang(title, lang)
} else {
s = slug.Make(title)
}
// Transliteration may cause the slug to expand past the limit, so truncate again
s, _ = parse.TruncToWord(s, 80)
return strings.TrimFunc(s, func(r rune) bool {
// TruncToWord doesn't respect words in a slug, since spaces are replaced
// with hyphens. So remove any trailing hyphens.
return r == '-'
})
}
// isFontValid returns whether or not the submitted post's appearance is valid.
func (p *SubmittedPost) isFontValid() bool {
validFonts := map[string]bool{
"norm": true,
"sans": true,
"mono": true,
"wrap": true,
"code": true,
}
_, valid := validFonts[p.Font]
return valid
}
func getRawPost(app *App, friendlyID string) *RawPost {
var content, font, title string
var isRTL sql.NullBool
var lang sql.NullString
var ownerID sql.NullInt64
var created time.Time
err := app.db.QueryRow("SELECT title, content, text_appearance, language, rtl, created, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&title, &content, &font, &lang, &isRTL, &created, &ownerID)
switch {
case err == sql.ErrNoRows:
return &RawPost{Content: "", Found: false, Gone: false}
case err != nil:
return &RawPost{Content: "", Found: true, Gone: false}
}
return &RawPost{Title: title, Content: content, Font: font, Created: created, IsRTL: isRTL, Language: lang, OwnerID: ownerID.Int64, Found: true, Gone: content == ""}
}
// TODO; return a Post!
func getRawCollectionPost(app *App, slug, collAlias string) *RawPost {
var id, title, content, font string
var isRTL sql.NullBool
var lang sql.NullString
var created time.Time
var ownerID null.Int
var views int64
var err error
if app.cfg.App.SingleUser {
err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, owner_id FROM posts WHERE slug = ? AND collection_id = 1", slug).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &ownerID)
} else {
err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, owner_id FROM posts WHERE slug = ? AND collection_id = (SELECT id FROM collections WHERE alias = ?)", slug, collAlias).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &ownerID)
}
switch {
case err == sql.ErrNoRows:
return &RawPost{Content: "", Found: false, Gone: false}
case err != nil:
return &RawPost{Content: "", Found: true, Gone: false}
}
return &RawPost{
Id: id,
Slug: slug,
Title: title,
Content: content,
Font: font,
Created: created,
IsRTL: isRTL,
Language: lang,
OwnerID: ownerID.Int64,
Found: true,
Gone: content == "",
Views: views,
}
}
func isRaw(r *http.Request) bool {
vars := mux.Vars(r)
slug := vars["slug"]
// NOTE: until this is done better, be sure to keep this in parity with
// isRaw in viewCollectionPost() and handleViewPost()
isJSON := strings.HasSuffix(slug, ".json")
isXML := strings.HasSuffix(slug, ".xml")
isMarkdown := strings.HasSuffix(slug, ".md")
return strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown
}
func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
slug := vars["slug"]
// NOTE: until this is done better, be sure to keep this in parity with
// isRaw() and handleViewPost()
isJSON := strings.HasSuffix(slug, ".json")
isXML := strings.HasSuffix(slug, ".xml")
isMarkdown := strings.HasSuffix(slug, ".md")
isRaw := strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown
cr := &collectionReq{}
err := processCollectionRequest(cr, vars, w, r)
if err != nil {
return err
}
// Check for hellbanned users
u, err := checkUserForCollection(app, cr, r, true)
if err != nil {
return err
}
// Normalize the URL, redirecting user to consistent post URL
if slug != strings.ToLower(slug) {
loc := fmt.Sprintf("/%s", strings.ToLower(slug))
if !app.cfg.App.SingleUser {
loc = "/" + cr.alias + loc
}
return impart.HTTPError{http.StatusMovedPermanently, loc}
}
// Display collection if this is a collection
var c *Collection
if app.cfg.App.SingleUser {
c, err = app.db.GetCollectionByID(1)
} else {
c, err = app.db.GetCollection(cr.alias)
}
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
if err.Status == http.StatusNotFound {
// Redirect if necessary
newAlias := app.db.GetCollectionRedirect(cr.alias)
if newAlias != "" {
return impart.HTTPError{http.StatusFound, "/" + newAlias + "/" + slug}
}
}
}
return err
}
c.hostName = app.cfg.App.Host
// Check collection permissions
if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) {
return ErrPostNotFound
}
if c.IsProtected() && ((u == nil || u.ID != c.OwnerID) && !isAuthorizedForCollection(app, c.Alias, r)) {
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug}
}
cr.isCollOwner = u != nil && c.OwnerID == u.ID
if isRaw {
slug = strings.Split(slug, ".")[0]
}
// Fetch extra data about the Collection
// TODO: refactor out this logic, shared in collection.go:fetchCollection()
coll := &CollectionObj{Collection: *c}
owner, err := app.db.GetUserByID(coll.OwnerID)
if err != nil {
// Log the error and just continue
log.Error("Error getting user for collection: %v", err)
} else {
coll.Owner = owner
}
+ postFound := true
p, err := app.db.GetPost(slug, coll.ID)
if err != nil {
- if err == ErrCollectionPageNotFound && slug == "feed" {
- // User tried to access blog feed without a trailing slash, and
- // there's no post with a slug "feed"
- return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/feed/"}
+ if err == ErrCollectionPageNotFound {
+ postFound = false
+
+ if slug == "feed" {
+ // User tried to access blog feed without a trailing slash, and
+ // there's no post with a slug "feed"
+ return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/feed/"}
+ }
+
+ po := &Post{
+ Slug: null.NewString(slug, true),
+ Font: "norm",
+ Language: zero.NewString("en", true),
+ RTL: zero.NewBool(false, true),
+ Content: `This page is missing.
+
+Are you sure it was ever here?`,
+ }
+ pp := po.processPost()
+ p = &pp
+ } else {
+ return err
}
- return err
}
p.IsOwner = owner != nil && p.OwnerID.Valid && owner.ID == p.OwnerID.Int64
p.Collection = coll
p.IsTopLevel = app.cfg.App.SingleUser
// Check if post has been unpublished
if p.Content == "" && p.Title.String == "" {
return impart.HTTPError{http.StatusGone, "Post was unpublished."}
}
// Serve collection post
if isRaw {
contentType := "text/plain"
if isJSON {
contentType = "application/json"
} else if isXML {
contentType = "application/xml"
} else if isMarkdown {
contentType = "text/markdown"
}
w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType))
+ if !postFound {
+ w.WriteHeader(http.StatusNotFound)
+ fmt.Fprintf(w, "Post not found.")
+ // TODO: return error instead, so status is correctly reflected in logs
+ return nil
+ }
if isMarkdown && p.Title.String != "" {
fmt.Fprintf(w, "# %s\n\n", p.Title.String)
}
fmt.Fprint(w, p.Content)
} else if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
+ if !postFound {
+ return ErrCollectionPageNotFound
+ }
p.extractData()
ap := p.ActivityObject()
ap.Context = []interface{}{activitystreams.Namespace}
return impart.RenderActivityJSON(w, ap, http.StatusOK)
} else {
p.extractData()
p.Content = strings.Replace(p.Content, "", "", 1)
// TODO: move this to function
p.formatContent(cr.isCollOwner)
tp := struct {
*PublicPost
page.StaticPage
IsOwner bool
IsPinned bool
IsCustomDomain bool
PinnedPosts *[]PublicPost
+ IsFound bool
}{
PublicPost: p,
StaticPage: pageForReq(app, r),
IsOwner: cr.isCollOwner,
IsCustomDomain: cr.isCustomDomain,
+ IsFound: postFound,
}
tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll)
tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p)
+
+ if !postFound {
+ w.WriteHeader(http.StatusNotFound)
+ }
if err := templates["collection-post"].ExecuteTemplate(w, "post", tp); err != nil {
log.Error("Error in collection-post template: %v", err)
}
}
go func() {
if p.OwnerID.Valid {
// Post is owned by someone. Don't update stats if owner is viewing the post.
if u != nil && p.OwnerID.Int64 == u.ID {
return
}
}
// Update stats for non-raw post views
if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) {
_, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE slug = ? AND collection_id = ?", slug, coll.ID)
if err != nil {
log.Error("Unable to update posts count: %v", err)
}
}
}()
return nil
}
// TODO: move this to utils after making it more generic
func PostsContains(sl *[]PublicPost, s *PublicPost) bool {
for _, e := range *sl {
if e.ID == s.ID {
return true
}
}
return false
}
func (p *Post) extractData() {
p.Tags = tags.Extract(p.Content)
p.extractImages()
}
func (rp *RawPost) UserFacingCreated() string {
return rp.Created.Format(postMetaDateFormat)
}
func (rp *RawPost) Created8601() string {
return rp.Created.Format("2006-01-02T15:04:05Z")
}
var imageURLRegex = regexp.MustCompile(`(?i)^https?:\/\/[^ ]*\.(gif|png|jpg|jpeg|image)$`)
func (p *Post) extractImages() {
matches := extract.ExtractUrls(p.Content)
urls := map[string]bool{}
for i := range matches {
u := matches[i].Text
if !imageURLRegex.MatchString(u) {
continue
}
urls[u] = true
}
resURLs := make([]string, 0)
for k := range urls {
resURLs = append(resURLs, k)
}
p.Images = resURLs
}
diff --git a/sitemap.go b/sitemap.go
index 5c37366..4dfd953 100644
--- a/sitemap.go
+++ b/sitemap.go
@@ -1,105 +1,109 @@
/*
* Copyright © 2018-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 writefreely
import (
"fmt"
- "github.com/gorilla/mux"
- "github.com/ikeikeikeike/go-sitemap-generator/stm"
- "github.com/writeas/web-core/log"
"net/http"
"time"
+
+ "github.com/gorilla/mux"
+ "github.com/ikeikeikeike/go-sitemap-generator/v2/stm"
+ "github.com/writeas/web-core/log"
)
func buildSitemap(host, alias string) *stm.Sitemap {
- sm := stm.NewSitemap()
+ sm := stm.NewSitemap(0)
sm.SetDefaultHost(host)
if alias != "/" {
sm.SetSitemapsPath(alias)
}
sm.Create()
// Note: Do not call `sm.Finalize()` because it flushes
// the underlying datastructure from memory to disk.
return sm
}
func handleViewSitemap(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
// Determine canonical blog URL
alias := vars["collection"]
subdomain := vars["subdomain"]
isSubdomain := subdomain != ""
if isSubdomain {
alias = subdomain
}
host := fmt.Sprintf("%s/%s/", app.cfg.App.Host, alias)
var c *Collection
var err error
pre := "/"
if app.cfg.App.SingleUser {
c, err = app.db.GetCollectionByID(1)
} else {
c, err = app.db.GetCollection(alias)
}
if err != nil {
return err
}
c.hostName = app.cfg.App.Host
if !isSubdomain {
pre += alias + "/"
}
host = c.CanonicalURL()
sm := buildSitemap(host, pre)
posts, err := app.db.GetPosts(c, 0, false, false, false)
if err != nil {
log.Error("Error getting posts: %v", err)
return err
}
lastSiteMod := time.Now()
for i, p := range *posts {
if i == 0 {
lastSiteMod = p.Updated
}
u := stm.URL{
- "loc": p.Slug.String,
- "changefreq": "weekly",
- "mobile": true,
- "lastmod": p.Updated,
+ {"loc", p.Slug.String},
+ {"changefreq", "weekly"},
+ {"mobile", true},
+ {"lastmod", p.Updated},
}
if len(p.Images) > 0 {
imgs := []stm.URL{}
for _, i := range p.Images {
- imgs = append(imgs, stm.URL{"loc": i, "title": ""})
+ imgs = append(imgs, stm.URL{
+ {"loc", i},
+ {"title", ""},
+ })
}
- u["image"] = imgs
+ u = append(u, []interface{}{"image", imgs})
}
sm.Add(u)
}
// Add top URL
sm.Add(stm.URL{
- "loc": pre,
- "changefreq": "daily",
- "priority": "1.0",
- "lastmod": lastSiteMod,
+ {"loc", pre},
+ {"changefreq", "daily"},
+ {"priority", "1.0"},
+ {"lastmod", lastSiteMod},
})
w.Write(sm.XMLContent())
return nil
}
diff --git a/templates/collection-post.tmpl b/templates/collection-post.tmpl
index 06bfa67..7075226 100644
--- a/templates/collection-post.tmpl
+++ b/templates/collection-post.tmpl
@@ -1,128 +1,130 @@
{{define "post"}}
{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{.Collection.DisplayTitle}}
+ {{ if .IsFound }}
{{if gt .Views 1}}
{{end}}
{{if gt (len .Images) 0}} {{else}} {{end}}
{{range .Images}} {{else}} {{end}}
+ {{ end }}
{{if .Collection.StyleSheet}}{{end}}
{{if .Collection.RenderMathJax}}
{{template "mathjax" . }}
{{end}}
{{template "highlighting" .}}
{{if .PinnedPosts}}
{{range .PinnedPosts}}{{.PlainDisplayTitle}} {{end}}
{{end}}
- {{ if .IsOwner }}{{largeNumFmt .Views}} {{pluralize "view" "views" .Views}}
+ {{ if and .IsOwner .IsFound }}{{largeNumFmt .Views}} {{pluralize "view" "views" .Views}}
Edit
{{if .IsPinned}}Unpin {{end}}
{{ end }}
- {{if .IsScheduled}}Scheduled
{{end}}{{if .Title.String}}{{.FormattedDisplayTitle}} {{end}}{{.HTMLContent}}
+ {{if .IsScheduled}}Scheduled
{{end}}{{if .Title.String}}{{.FormattedDisplayTitle}} {{end}}{{.HTMLContent}}
{{ if .Collection.ShowFooterBranding }}
{{ end }}
{{if .Collection.CanShowScript}}
{{range .Collection.ExternalScripts}}{{end}}
{{if .Collection.Script}}{{end}}
{{end}}
{{end}}
diff --git a/templates/collection-tags.tmpl b/templates/collection-tags.tmpl
index 6849fee..7cad3b7 100644
--- a/templates/collection-tags.tmpl
+++ b/templates/collection-tags.tmpl
@@ -1,194 +1,194 @@
{{define "collection-tags"}}
{{.Tag}} — {{.Collection.DisplayTitle}}
{{if not .Collection.IsPrivate}} {{end}}
{{if gt .Views 1}}
{{end}}
{{if .Collection.StyleSheet}}{{end}}
{{if .Collection.RenderMathJax}}
{{template "mathjax" .}}
{{end}}
{{template "highlighting" . }}
{{if .Posts}}{{else}}{{end}}
{{.Tag}}
{{template "posts" .}}
{{if .Posts}}{{else}}{{end}}
{{ if .Collection.ShowFooterBranding }}
{{ end }}
{{if .CanShowScript}}
{{range .ExternalScripts}}{{end}}
{{if .Collection.Script}}{{end}}
{{end}}
{{if .IsOwner}}
{{end}}
{{end}}
diff --git a/templates/collection.tmpl b/templates/collection.tmpl
index 18942a9..6623a2e 100644
--- a/templates/collection.tmpl
+++ b/templates/collection.tmpl
@@ -1,229 +1,229 @@
{{define "collection"}}
{{.DisplayTitle}}{{if not .SingleUser}} — {{.SiteName}}{{end}}
{{if gt .CurrentPage 1}} {{end}}
{{if lt .CurrentPage .TotalPages}} {{end}}
{{if not .IsPrivate}} {{end}}
{{if .StyleSheet}}{{end}}
{{if .RenderMathJax}}
{{template "mathjax" .}}
{{end}}
{{template "highlighting" . }}
{{if or .IsOwner .SingleUser}} {{end}}
{{if .Description}}{{.Description}}
{{end}}
{{/*if not .Public/*}}
{{/*end*/}}
{{if .PinnedPosts}}
{{range .PinnedPosts}}{{.PlainDisplayTitle}} {{end}}
{{end}}
{{if .Posts}}{{else}}{{end}}
{{if .ShowFooterBranding }}
{{ end }}
{{if .CanShowScript}}
{{range .ExternalScripts}}{{end}}
{{if .Script}}{{end}}
{{end}}
{{end}}
diff --git a/templates/user/admin.tmpl b/templates/user/admin.tmpl
index 90b5d70..3cb81be 100644
--- a/templates/user/admin.tmpl
+++ b/templates/user/admin.tmpl
@@ -1,182 +1,190 @@
{{define "admin"}}
{{template "header" .}}
{{template "admin-header" .}}
{{if .Message}}
{{.Message}}
{{end}}
On this page
Resources
App Configuration
Read more in the configuration docs .
{{if .ConfigMessage}}
{{.ConfigMessage}}
{{end}}
Application
WriteFreely
{{.Version}}
Server Uptime
{{.SysStatus.Uptime}}
Current Goroutines
{{.SysStatus.NumGoroutine}}
Current memory usage
{{.SysStatus.MemAllocated}}
Total mem allocated
{{.SysStatus.MemTotal}}
Memory obtained
{{.SysStatus.MemSys}}
Pointer lookup times
{{.SysStatus.Lookups}}
Memory allocate times
{{.SysStatus.MemMallocs}}
Memory free times
{{.SysStatus.MemFrees}}
Current heap usage
{{.SysStatus.HeapAlloc}}
Heap memory obtained
{{.SysStatus.HeapSys}}
Heap memory idle
{{.SysStatus.HeapIdle}}
Heap memory in use
{{.SysStatus.HeapInuse}}
Heap memory released
{{.SysStatus.HeapReleased}}
Heap objects
{{.SysStatus.HeapObjects}}
Bootstrap stack usage
{{.SysStatus.StackInuse}}
Stack memory obtained
{{.SysStatus.StackSys}}
MSpan structures in use
{{.SysStatus.MSpanInuse}}
MSpan structures obtained
{{.SysStatus.HeapSys}}
MCache structures in use
{{.SysStatus.MCacheInuse}}
MCache structures obtained
{{.SysStatus.MCacheSys}}
Profiling bucket hash table obtained
{{.SysStatus.BuckHashSys}}
GC metadata obtained
{{.SysStatus.GCSys}}
Other system allocation obtained
{{.SysStatus.OtherSys}}
Next GC recycle
{{.SysStatus.NextGC}}
Since last GC
{{.SysStatus.LastGC}}
Total GC pause
{{.SysStatus.PauseTotalNs}}
Last GC pause
{{.SysStatus.PauseNs}}
GC times
{{.SysStatus.NumGC}}
{{template "footer" .}}
{{template "body-end" .}}
{{end}}
diff --git a/templates/user/articles.tmpl b/templates/user/articles.tmpl
index 4956301..67d3e0b 100644
--- a/templates/user/articles.tmpl
+++ b/templates/user/articles.tmpl
@@ -1,145 +1,145 @@
{{define "articles"}}
{{template "header" .}}
{{if .Flashes}}
{{range .Flashes}}{{.}} {{end}}
{{end}}
{{ if .AnonymousPosts }}
{{ range $el := .AnonymousPosts }}
{{.DisplayDate}}
edit
delete
{{ if $.Collections }}
{{if gt (len $.Collections) 1}}
{{range $.Collections}}{{.DisplayTitle}} {{end}}
move to...
{{else}}
{{range $.Collections}}
move to {{.DisplayTitle}}
{{end}}
{{end}}
{{ end }}
{{if .Summary}}
{{.Summary}}
{{end}}
{{end}}
{{ else }}
You haven't saved any drafts yet.
-
They'll show up here once you do. Find your blog posts from the Blogs page.
-
Start writing
{{ end }}
+
They'll show up here once you do. {{if not .SingleUser}}Find your blog posts from the Blogs page.{{end}}
+
Start writing
{{ end }}
{{template "footer" .}}
{{end}}
diff --git a/templates/user/collection.tmpl b/templates/user/collection.tmpl
index 08e8886..8af3bda 100644
--- a/templates/user/collection.tmpl
+++ b/templates/user/collection.tmpl
@@ -1,236 +1,238 @@
{{define "upgrade"}}
Upgrade for $40 / year to edit.
{{end}}
{{define "collection"}}
{{template "header" .}}
Customize {{.DisplayTitle}} view blog
{{if .Flashes}}
{{range .Flashes}}{{.}} {{end}}
{{end}}
Are you sure you want to delete this blog?
{{template "footer" .}}
{{end}}
diff --git a/templates/user/include/footer.tmpl b/templates/user/include/footer.tmpl
index a2b2f06..e60f86f 100644
--- a/templates/user/include/footer.tmpl
+++ b/templates/user/include/footer.tmpl
@@ -1,40 +1,40 @@
{{define "footer"}}
{{template "foot" .}}
{{template "body-end" .}}
{{end}}
{{define "foot"}}
{{end}}
{{define "body-end"}}