Page MenuHomeMusing Studio

No OneTemporary

diff --git a/app.go b/app.go
index c7bdbb5..0ae5110 100644
--- a/app.go
+++ b/app.go
@@ -1,987 +1,988 @@
/*
* Copyright © 2018-2021 Musing Studio 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"
_ "embed"
"fmt"
"html/template"
"io/ioutil"
"net"
"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"
stripmd "github.com/writeas/go-strip-markdown/v2"
"github.com/writeas/impart"
"github.com/writeas/web-core/auth"
"github.com/writeas/web-core/converter"
"github.com/writeas/web-core/log"
"golang.org/x/crypto/acme/autocert"
"github.com/writefreely/writefreely/author"
"github.com/writefreely/writefreely/config"
"github.com/writefreely/writefreely/key"
"github.com/writefreely/writefreely/migrations"
"github.com/writefreely/writefreely/page"
)
const (
staticDir = "static"
+ mediaDir = "media"
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.13.2"
// 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.Store
formDecoder *schema.Decoder
updates *updatesCache
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
}
func (app *App) SessionStore() sessions.Store {
return app.sessionStore
}
func (app *App) SetSessionStore(s sessions.Store) {
app.sessionStore = s
}
// 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)
}
executable, err := os.Executable()
if err != nil {
executable = "writefreely"
} else {
executable = filepath.Base(executable)
}
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
}
if debugging {
log.Info(" %s", csrfKeyPath)
}
app.keys.CSRFKey, err = ioutil.ReadFile(csrfKeyPath)
if err != nil {
if os.IsNotExist(err) {
log.Error(`Missing key: %s.
Run this command to generate missing keys:
%s keys generate
`, csrfKeyPath, executable)
}
return err
}
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. It checks the configuration and
// authentication state to show the correct page.
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 app.cfg.App.Chorus {
// This instance is focused on reading, so show Reader on home route if not
// private or a private-instance user is logged in.
if !app.cfg.App.Private || u != nil {
return viewLocalTimeline(app, w, r)
}
}
if u != nil {
// User is logged in, so show the Pad
return handleViewPad(app, w, r)
}
if app.cfg.App.Private {
return viewLogin(app, w, r)
}
if land := app.cfg.App.LandingPath(); land != "/" {
return impart.HTTPError{http.StatusFound, land}
}
}
return handleViewLanding(app, w, r)
}
func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error {
forceLanding := r.FormValue("landing") == "1"
p := struct {
page.StaticPage
*OAuthButtons
Flashes []template.HTML
Banner template.HTML
Content template.HTML
ForcedLanding bool
}{
StaticPage: pageForReq(app, r),
OAuthButtons: NewOAuthButtons(app.Config()),
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), "", app.cfg))
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), "", app.cfg))
// 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), "", app.cfg))
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,
}
// Use custom style, if file exists
if _, err := os.Stat(filepath.Join(staticDir, "local", "custom.css")); err == nil {
p.CustomCSS = true
}
// 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.IsAdmin = u != nil && u.IsAdmin()
p.CanInvite = canUserInvite(app.cfg, p.IsAdmin)
}
}
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().InitUpdates()
apper.App().InitSession()
apper.App().InitDecoder()
err = ConnectToDatabase(apper.App())
if err != nil {
return nil, fmt.Errorf("connect to DB: %s", err)
}
initActivityPub(apper.App())
// 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 gopher server
if app.cfg.Server.GopherPort > 0 && !app.cfg.App.Private {
go initGopher(app)
}
// Start web application server
var bindAddress = app.cfg.Server.Bind
if bindAddress == "" {
bindAddress = "localhost"
}
var err error
if app.cfg.IsSecureStandalone() {
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.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 {
network := "tcp"
protocol := "http"
if strings.HasPrefix(bindAddress, "/") {
network = "unix"
protocol = "http+unix"
// old sockets will remain after server closes;
// we need to delete them in order to open new ones
err = os.Remove(bindAddress)
if err != nil && !os.IsNotExist(err) {
log.Error("%s already exists but could not be removed: %v", bindAddress, err)
os.Exit(1)
}
} else {
bindAddress = fmt.Sprintf("%s:%d", bindAddress, app.cfg.Server.Port)
}
log.Info("Serving on %s://%s", protocol, bindAddress)
log.Info("---")
listener, err := net.Listen(network, bindAddress)
if err != nil {
log.Error("Could not bind to address: %v", err)
os.Exit(1)
}
if network == "unix" {
err = os.Chmod(bindAddress, 0o666)
if err != nil {
log.Error("Could not update socket permissions: %v", err)
os.Exit(1)
}
}
defer listener.Close()
err = http.Serve(listener, 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
}
// FormatVersion constructs the version string for the application
func FormatVersion() string {
return serverSoftware + " " + softwareVer
}
// OutputVersion prints out the version of the application.
func OutputVersion() {
fmt.Println(FormatVersion())
}
// 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(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
}
err = generateKey(csrfKeyPath)
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
}
// DoDeleteAccount runs the confirmation and account delete process.
func DoDeleteAccount(apper Apper, username string) error {
// Connect to the database
apper.LoadConfig()
connectToDatabase(apper.App())
defer shutdown(apper.App())
// check user exists
u, err := apper.App().db.GetUserForAuth(username)
if err != nil {
log.Error("%s", err)
os.Exit(1)
}
userID := u.ID
// do not delete the admin account
// TODO: check for other admins and skip?
if u.IsAdmin() {
log.Error("Can not delete admin account")
os.Exit(1)
}
// confirm deletion, w/ w/out posts
prompt := promptui.Prompt{
Templates: &promptui.PromptTemplates{
Success: "{{ . | bold | faint }}: ",
},
Label: fmt.Sprintf("Really delete user : %s", username),
IsConfirm: true,
}
_, err = prompt.Run()
if err != nil {
log.Info("Aborted...")
os.Exit(0)
}
log.Info("Deleting...")
err = apper.App().db.DeleteAccount(userID)
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&tls=%t", 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()), app.cfg.Database.TLS))
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(2)
} 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()
if strings.HasPrefix(app.cfg.Server.Bind, "/") {
// Clean up socket
log.Info("Removing socket file...")
err := os.Remove(app.cfg.Server.Bind)
if err != nil {
log.Error("Unable to remove socket: %s", err)
os.Exit(1)
}
log.Info("Success.")
}
}
// 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(apper.App().Config(), u, desiredUsername, "")
if err != nil {
return fmt.Errorf("Unable to create user: %s", err)
}
log.Info("Done!")
return nil
}
//go:embed schema.sql
var schemaSql string
//go:embed sqlite.sql
var sqliteSql string
func adminInitDatabase(app *App) error {
var schema string
if app.cfg.Database.Type == driverSQLite {
schema = sqliteSql
} else {
schema = schemaSql
}
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
}
// ServerUserAgent returns a User-Agent string to use in external requests. The
// hostName parameter may be left empty.
func ServerUserAgent(hostName string) string {
hostUAStr := ""
if hostName != "" {
hostUAStr = "; +" + hostName
}
return "Go (" + serverSoftware + "/" + softwareVer + hostUAStr + ")"
}
diff --git a/config/config.go b/config/config.go
index 2065ddf..5213457 100644
--- a/config/config.go
+++ b/config/config.go
@@ -1,295 +1,301 @@
/*
* Copyright © 2018-2021 Musing Studio 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 (
"net/url"
"strings"
"github.com/go-ini/ini"
"github.com/writeas/web-core/log"
"golang.org/x/net/idna"
)
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"`
+ MediaParentDir string `ini:"media_parent_dir"`
PagesParentDir string `ini:"pages_parent_dir"`
KeysParentDir string `ini:"keys_parent_dir"`
HashSeed string `ini:"hash_seed"`
GopherPort int `ini:"gopher_port"`
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"`
TLS bool `ini:"tls"`
}
WriteAsOauthCfg struct {
ClientID string `ini:"client_id"`
ClientSecret string `ini:"client_secret"`
AuthLocation string `ini:"auth_location"`
TokenLocation string `ini:"token_location"`
InspectLocation string `ini:"inspect_location"`
CallbackProxy string `ini:"callback_proxy"`
CallbackProxyAPI string `ini:"callback_proxy_api"`
}
GitlabOauthCfg struct {
ClientID string `ini:"client_id"`
ClientSecret string `ini:"client_secret"`
Host string `ini:"host"`
DisplayName string `ini:"display_name"`
CallbackProxy string `ini:"callback_proxy"`
CallbackProxyAPI string `ini:"callback_proxy_api"`
}
GiteaOauthCfg struct {
ClientID string `ini:"client_id"`
ClientSecret string `ini:"client_secret"`
Host string `ini:"host"`
DisplayName string `ini:"display_name"`
CallbackProxy string `ini:"callback_proxy"`
CallbackProxyAPI string `ini:"callback_proxy_api"`
}
SlackOauthCfg struct {
ClientID string `ini:"client_id"`
ClientSecret string `ini:"client_secret"`
TeamID string `ini:"team_id"`
CallbackProxy string `ini:"callback_proxy"`
CallbackProxyAPI string `ini:"callback_proxy_api"`
}
GenericOauthCfg struct {
ClientID string `ini:"client_id"`
ClientSecret string `ini:"client_secret"`
Host string `ini:"host"`
DisplayName string `ini:"display_name"`
CallbackProxy string `ini:"callback_proxy"`
CallbackProxyAPI string `ini:"callback_proxy_api"`
TokenEndpoint string `ini:"token_endpoint"`
InspectEndpoint string `ini:"inspect_endpoint"`
AuthEndpoint string `ini:"auth_endpoint"`
Scope string `ini:"scope"`
AllowDisconnect bool `ini:"allow_disconnect"`
MapUserID string `ini:"map_user_id"`
MapUsername string `ini:"map_username"`
MapDisplayName string `ini:"map_display_name"`
MapEmail string `ini:"map_email"`
}
// 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"`
Editor string `ini:"editor"`
JSDisabled bool `ini:"disable_js"`
WebFonts bool `ini:"webfonts"`
Landing string `ini:"landing"`
SimpleNav bool `ini:"simple_nav"`
WFModesty bool `ini:"wf_modesty"`
// Site functionality
Chorus bool `ini:"chorus"`
Forest bool `ini:"forest"` // The admin cares about the forest, not the trees. Hide unnecessary technical info.
DisableDrafts bool `ini:"disable_drafts"`
// Users
SingleUser bool `ini:"single_user"`
OpenRegistration bool `ini:"open_registration"`
OpenDeletion bool `ini:"open_deletion"`
MinUsernameLen int `ini:"min_username_len"`
MaxBlogs int `ini:"max_blogs"`
// Options for public instances
// Federation
Federation bool `ini:"federation"`
PublicStats bool `ini:"public_stats"`
Monetization bool `ini:"monetization"`
NotesOnly bool `ini:"notes_only"`
// Access
Private bool `ini:"private"`
// Additional functions
LocalTimeline bool `ini:"local_timeline"`
UserInvites string `ini:"user_invites"`
// Defaults
DefaultVisibility string `ini:"default_visibility"`
// Check for Updates
UpdateChecks bool `ini:"update_checks"`
// Disable password authentication if use only Oauth
DisablePasswordAuth bool `ini:"disable_password_auth"`
+
+ AllowUploadMedia bool `ini:"allow_upload_media"`
+ MediaMaxSize int64 `ini:"media_max_size"`
}
// Config holds the complete configuration for running a writefreely instance
Config struct {
Server ServerCfg `ini:"server"`
Database DatabaseCfg `ini:"database"`
App AppCfg `ini:"app"`
SlackOauth SlackOauthCfg `ini:"oauth.slack"`
WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"`
GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"`
GiteaOauth GiteaOauthCfg `ini:"oauth.gitea"`
GenericOauth GenericOauthCfg `ini:"oauth.generic"`
}
)
// 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,
+ MediaMaxSize: 10,
Federation: true,
PublicStats: true,
+ AllowUploadMedia: false,
},
}
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
}
func (ac AppCfg) SignupPath() string {
if !ac.OpenRegistration {
return ""
}
if ac.Chorus || ac.Private || (ac.Landing != "" && ac.Landing != "/") {
return "/signup"
}
return "/"
}
// 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
}
// Do any transformations
u, err := url.Parse(uc.App.Host)
if err != nil {
return nil, err
}
d, err := idna.ToASCII(u.Hostname())
if err != nil {
log.Error("idna.ToASCII for %s: %s", u.Hostname(), err)
return nil, err
}
uc.App.Host = u.Scheme + "://" + d
if u.Port() != "" {
uc.App.Host += ":" + u.Port()
}
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/errors.go b/errors.go
index f0d3099..ae9fd5f 100644
--- a/errors.go
+++ b/errors.go
@@ -1,62 +1,63 @@
/*
* Copyright © 2018-2020 Musing Studio 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 (
"net/http"
"github.com/writeas/impart"
)
// Commonly returned HTTP errors
var (
ErrBadFormData = impart.HTTPError{http.StatusBadRequest, "Expected valid form data."}
ErrBadJSON = impart.HTTPError{http.StatusBadRequest, "Expected valid JSON object."}
ErrBadJSONArray = impart.HTTPError{http.StatusBadRequest, "Expected valid JSON array."}
ErrBadAccessToken = impart.HTTPError{http.StatusUnauthorized, "Invalid access token."}
ErrNoAccessToken = impart.HTTPError{http.StatusBadRequest, "Authorization token required."}
ErrNotLoggedIn = impart.HTTPError{http.StatusUnauthorized, "Not logged in."}
ErrForbiddenCollection = impart.HTTPError{http.StatusForbidden, "You don't have permission to add to this collection."}
ErrForbiddenEditPost = impart.HTTPError{http.StatusForbidden, "You don't have permission to update this post."}
ErrUnauthorizedEditPost = impart.HTTPError{http.StatusUnauthorized, "Invalid editing credentials."}
ErrUnauthorizedGeneral = impart.HTTPError{http.StatusUnauthorized, "You don't have permission to do that."}
ErrBadRequestedType = impart.HTTPError{http.StatusNotAcceptable, "Bad requested Content-Type."}
ErrCollectionUnauthorizedRead = impart.HTTPError{http.StatusUnauthorized, "You don't have permission to access this collection."}
ErrNoPublishableContent = impart.HTTPError{http.StatusBadRequest, "Supply something to publish."}
ErrInternalGeneral = impart.HTTPError{http.StatusInternalServerError, "The humans messed something up. They've been notified."}
ErrInternalCookieSession = impart.HTTPError{http.StatusInternalServerError, "Could not get cookie session."}
ErrUnavailable = impart.HTTPError{http.StatusServiceUnavailable, "Service temporarily unavailable due to high load."}
ErrCollectionNotFound = impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."}
ErrCollectionGone = impart.HTTPError{http.StatusGone, "This blog was unpublished."}
ErrCollectionPageNotFound = impart.HTTPError{http.StatusNotFound, "Collection page doesn't exist."}
ErrPostNotFound = impart.HTTPError{Status: http.StatusNotFound, Message: "Post not found."}
+ ErrFileNotFound = impart.HTTPError{Status: http.StatusNotFound, Message: "File not found."}
ErrPostBanned = impart.HTTPError{Status: http.StatusGone, Message: "Post removed."}
ErrPostUnpublished = impart.HTTPError{Status: http.StatusGone, Message: "Post unpublished by author."}
ErrPostFetchError = impart.HTTPError{Status: http.StatusInternalServerError, Message: "We encountered an error getting the post. The humans have been alerted."}
ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."}
ErrRemoteUserNotFound = impart.HTTPError{http.StatusNotFound, "Remote user not found."}
ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."}
ErrUserSilenced = impart.HTTPError{http.StatusForbidden, "Account is silenced."}
ErrDisabledPasswordAuth = impart.HTTPError{http.StatusForbidden, "Password authentication is disabled."}
)
// Post operation errors
var (
ErrPostNoUpdatableVals = impart.HTTPError{http.StatusBadRequest, "Supply some properties to update."}
)
diff --git a/pad.go b/pad.go
index 55dde70..f065cfb 100644
--- a/pad.go
+++ b/pad.go
@@ -1,193 +1,397 @@
/*
* Copyright © 2018-2021 Musing Studio 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 (
+ "os"
+ "io"
+ "fmt"
+ "strconv"
+ "io/ioutil"
+ "path/filepath"
+ "encoding/json"
"net/http"
"strings"
+ uuid "github.com/nu7hatch/gouuid"
+
"github.com/gorilla/mux"
"github.com/writeas/impart"
"github.com/writeas/web-core/log"
"github.com/writefreely/writefreely/page"
)
func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
action := vars["action"]
slug := vars["slug"]
collAlias := vars["collection"]
if app.cfg.App.SingleUser {
// TODO: refactor all of this, especially for single-user blogs
c, err := app.db.GetCollectionByID(1)
if err != nil {
return err
}
collAlias = c.Alias
}
appData := &struct {
page.StaticPage
Post *RawPost
User *User
Blogs *[]Collection
Silenced bool
Editing bool // True if we're modifying an existing post
EditCollection *Collection // Collection of the post we're editing, if any
}{
StaticPage: pageForReq(app, r),
Post: &RawPost{Font: "norm"},
User: getUserSession(app, r),
}
var err error
if appData.User != nil {
appData.Blogs, err = app.db.GetPublishableCollections(appData.User, app.cfg.App.Host)
if err != nil {
log.Error("Unable to get user's blogs for Pad: %v", err)
}
appData.Silenced, err = app.db.IsUserSilenced(appData.User.ID)
if err != nil {
if err == ErrUserNotFound {
return err
}
log.Error("Unable to get user status for Pad: %v", err)
}
}
padTmpl := app.cfg.App.Editor
if templates[padTmpl] == nil {
if padTmpl != "" {
log.Info("No template '%s' found. Falling back to default 'pad' template.", padTmpl)
}
padTmpl = "pad"
}
if action == "" && slug == "" {
// Not editing any post; simply render the Pad
if err = templates[padTmpl].ExecuteTemplate(w, "pad", appData); err != nil {
log.Error("Unable to execute template: %v", err)
}
return nil
}
// Retrieve post information for editing
appData.Editing = true
// Make sure this isn't cached, so user doesn't accidentally lose data
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Expires", "Thu, 04 Oct 1990 20:00:00 GMT")
if slug != "" {
// TODO: refactor all of this, especially for single-user blogs
appData.Post = getRawCollectionPost(app, slug, collAlias)
if appData.Post.OwnerID != appData.User.ID {
// TODO: add ErrForbiddenEditPost message to flashes
return impart.HTTPError{http.StatusFound, r.URL.Path[:strings.LastIndex(r.URL.Path, "/edit")]}
}
appData.EditCollection, err = app.db.GetCollectionForPad(collAlias)
if err != nil {
log.Error("Unable to GetCollectionForPad: %s", err)
return err
}
appData.EditCollection.hostName = app.cfg.App.Host
} else {
// Editing a floating article
appData.Post = getRawPost(app, action)
appData.Post.Id = action
}
if appData.Post.Gone {
return ErrPostUnpublished
} else if appData.Post.Found && (appData.Post.Title != "" || appData.Post.Content != "") {
// Got the post
} else if appData.Post.Found {
log.Error("Found post, but other conditions failed.")
return ErrPostFetchError
} else {
return ErrPostNotFound
}
if err = templates[padTmpl].ExecuteTemplate(w, "pad", appData); err != nil {
log.Error("Unable to execute template: %v", err)
}
return nil
}
+func okToEdit(app *App, w http.ResponseWriter, r *http.Request) error {
+ vars := mux.Vars(r)
+ action := vars["action"]
+ slug := vars["slug"]
+ collAlias := vars["collection"]
+ appData := &struct {
+ page.StaticPage
+ Post *RawPost
+ User *User
+ EditCollection *Collection // Collection of the post we're editing, if any
+ Flashes []string
+ NeedsToken bool
+ Silenced bool
+ }{
+ StaticPage: pageForReq(app, r),
+ Post: &RawPost{Font: "norm"},
+ User: getUserSession(app, r),
+ }
+ var err error
+ appData.Silenced, err = app.db.IsUserSilenced(appData.User.ID)
+ if err != nil {
+ log.Error("view meta: get user status: %v", err)
+ return ErrInternalGeneral
+ }
+
+ if action == "" && slug == "" {
+ return ErrPostNotFound
+ }
+
+ // Make sure this isn't cached, so user doesn't accidentally lose data
+ w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
+ w.Header().Set("Expires", "Thu, 28 Jul 1989 12:00:00 GMT")
+ if slug != "" {
+ appData.Post = getRawCollectionPost(app, slug, collAlias)
+ if appData.Post.OwnerID != appData.User.ID {
+ // TODO: add ErrForbiddenEditPost message to flashes
+ return impart.HTTPError{
+ http.StatusFound, r.URL.Path[:strings.LastIndex(r.URL.Path, "/meta")]}
+ }
+ if app.cfg.App.SingleUser {
+ // TODO: optimize this query just like we do in GetCollectionForPad (?)
+ appData.EditCollection, err = app.db.GetCollectionByID(1)
+ } else {
+ appData.EditCollection, err = app.db.GetCollectionForPad(collAlias)
+ }
+ if err != nil {
+ return err
+ }
+ appData.EditCollection.hostName = app.cfg.App.Host
+ } else {
+ // Editing a floating article
+ appData.Post = getRawPost(app, action)
+ appData.Post.Id = action
+ }
+ appData.NeedsToken = appData.User == nil || appData.User.ID != appData.Post.OwnerID
+
+ if appData.Post.Gone {
+ return ErrPostUnpublished
+ } else if appData.Post.Found && appData.Post.Content != "" {
+ // Got the post
+ } else if appData.Post.Found {
+ return ErrPostFetchError
+ } else {
+ return ErrPostNotFound
+ }
+ return nil
+}
+
+func handleDeleteFile(app *App, w http.ResponseWriter, r *http.Request) error {
+ vars := mux.Vars(r)
+ fileName := vars["filename"]
+ slug := vars["slug"]
+ user := getUserSession(app, r)
+ filePath := filepath.Join(app.cfg.Server.MediaParentDir, mediaDir, user.Username, slug) + "/" + fileName
+ err := os.Remove(filePath)
+ if err != nil {
+ w.WriteHeader(http.StatusNotFound)
+ return ErrFileNotFound
+ }
+ w.WriteHeader(http.StatusOK)
+ return nil
+}
+
func handleViewMeta(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
action := vars["action"]
slug := vars["slug"]
collAlias := vars["collection"]
appData := &struct {
page.StaticPage
Post *RawPost
User *User
EditCollection *Collection // Collection of the post we're editing, if any
Flashes []string
NeedsToken bool
Silenced bool
}{
StaticPage: pageForReq(app, r),
Post: &RawPost{Font: "norm"},
User: getUserSession(app, r),
}
var err error
appData.Silenced, err = app.db.IsUserSilenced(appData.User.ID)
if err != nil {
log.Error("view meta: get user status: %v", err)
return ErrInternalGeneral
}
if action == "" && slug == "" {
return ErrPostNotFound
}
// Make sure this isn't cached, so user doesn't accidentally lose data
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Expires", "Thu, 28 Jul 1989 12:00:00 GMT")
if slug != "" {
appData.Post = getRawCollectionPost(app, slug, collAlias)
if appData.Post.OwnerID != appData.User.ID {
// TODO: add ErrForbiddenEditPost message to flashes
return impart.HTTPError{http.StatusFound, r.URL.Path[:strings.LastIndex(r.URL.Path, "/meta")]}
}
if app.cfg.App.SingleUser {
// TODO: optimize this query just like we do in GetCollectionForPad (?)
appData.EditCollection, err = app.db.GetCollectionByID(1)
} else {
appData.EditCollection, err = app.db.GetCollectionForPad(collAlias)
}
if err != nil {
return err
}
appData.EditCollection.hostName = app.cfg.App.Host
} else {
// Editing a floating article
appData.Post = getRawPost(app, action)
appData.Post.Id = action
}
appData.NeedsToken = appData.User == nil || appData.User.ID != appData.Post.OwnerID
if appData.Post.Gone {
return ErrPostUnpublished
} else if appData.Post.Found && appData.Post.Content != "" {
// Got the post
} else if appData.Post.Found {
return ErrPostFetchError
} else {
return ErrPostNotFound
}
appData.Flashes, _ = getSessionFlashes(app, w, r, nil)
-
+ user := getUserSession(app, r)
+ mediaDirectoryPath := filepath.Join(app.cfg.Server.MediaParentDir, mediaDir,
+ user.Username, slug)
+ appData.Post.MediaFilesList, _ = getFilesListInPath(mediaDirectoryPath)
if err = templates["edit-meta"].ExecuteTemplate(w, "edit-meta", appData); err != nil {
log.Error("Unable to execute template: %v", err)
}
return nil
}
+
+func getNewFileName(path string, originalFieName string) (string, error) {
+ u, err := uuid.NewV4()
+ if err != nil {
+ log.Error("Unable to generate uuid: %v", err)
+ return "", err
+ }
+ extension := filepath.Ext(originalFieName)
+ return u.String() + extension, nil
+}
+
+func getFilesListInPath(path string) ([]string, error) {
+ var files []string
+ entries, err := ioutil.ReadDir(path)
+ if err != nil {
+ return nil, err
+ }
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ files = append(files, "/" + path + "/" + entry.Name())
+ }
+ }
+ return files, nil
+}
+
+func handleGetFile(app *App, w http.ResponseWriter, r *http.Request) error {
+ vars := mux.Vars(r)
+ slug := vars["slug"]
+ author := vars["author"]
+ filename := vars["filename"]
+ mediaDirectoryPath := filepath.Join(app.cfg.Server.MediaParentDir, mediaDir,
+ author, slug, filename)
+ filePath := mediaDirectoryPath
+ file, err := http.Dir("").Open(filePath)
+ if err != nil {
+ http.Error(w, "File not found", http.StatusNotFound)
+ return nil
+ }
+ defer file.Close()
+ fileInfo, err := file.Stat()
+ if err != nil {
+ http.Error(w, "Failed to get file information", http.StatusInternalServerError)
+ return nil
+ }
+ w.Header().Set("Content-Disposition", "attachment; filename="+fileInfo.Name())
+ w.Header().Set("Content-Type", "application/octet-stream")
+ w.Header().Set("Content-Length", strconv.FormatInt(fileInfo.Size(), 10))
+ http.ServeContent(w, r, fileInfo.Name(), fileInfo.ModTime(), file)
+ return nil
+}
+
+func handleUploadMedia(app *App, w http.ResponseWriter, r *http.Request) error {
+ maxUploadSize := app.cfg.App.MediaMaxSize * 1024 * 1024
+ r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
+ if err := r.ParseMultipartForm(maxUploadSize); err != nil {
+ errMsg := fmt.Sprintf("File size limit exceeded. The limit is: %d MB",
+ app.cfg.App.MediaMaxSize)
+ http.Error(w, errMsg, http.StatusBadRequest)
+ return nil
+ }
+
+ if err := okToEdit(app, w, r); err != nil {
+ return err
+ }
+ vars := mux.Vars(r)
+ slug := vars["slug"]
+ user := getUserSession(app, r)
+
+ if slug == "" {
+ return ErrPostNotFound
+ }
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return nil
+ }
+ file, handler, err := r.FormFile("file")
+ if err != nil {
+ http.Error(w, "Error retrieving the file", http.StatusInternalServerError)
+ return nil
+ }
+ defer file.Close()
+ mediaDirectoryPath := filepath.Join(app.cfg.Server.MediaParentDir, mediaDir,
+ user.Username, slug)
+ err = os.MkdirAll(mediaDirectoryPath, 0755)
+ if err != nil {
+ return err
+ } else {
+ }
+ newFileName, _ := getNewFileName(mediaDirectoryPath, handler.Filename)
+ newFilePath := filepath.Join(mediaDirectoryPath, newFileName)
+ dst, err := os.Create(newFilePath)
+ if err != nil {
+ http.Error(w, "Error saving the file", http.StatusInternalServerError)
+ return nil
+ }
+ defer dst.Close()
+ _, err = io.Copy(dst, file)
+ if err != nil {
+ http.Error(w, "Error copying the file", http.StatusInternalServerError)
+ return nil
+ }
+ response := map[string]string{
+ "message": "File uploaded successfully!",
+ "path": newFilePath,
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(response)
+ return nil
+}
diff --git a/posts.go b/posts.go
index e95532e..a529036 100644
--- a/posts.go
+++ b/posts.go
@@ -1,1641 +1,1642 @@
/*
* Copyright © 2018-2021 Musing Studio 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"
"net/url"
"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/v2"
"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/writefreely/writefreely/page"
"github.com/writefreely/writefreely/parse"
)
const (
// Post ID length bounds
minIDLen = 10
maxIDLen = 10
userPostIDLen = 10
postIDLen = 10
postMetaDateFormat = "2006-01-02 15:04:05"
shortCodePaid = "<!--paid-->"
)
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
Images []string
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"`
IsPaid bool `json:"paid"`
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:"-"`
URL string `json:"url,omitempty"`
Collection *CollectionObj `json:"collection,omitempty"`
}
CollectionPostPage struct {
*PublicPost
page.StaticPage
IsOwner bool
IsPinned bool
IsCustomDomain bool
Monetization string
PinnedPosts *[]PublicPost
IsFound bool
IsAdmin bool
CanInvite bool
Silenced bool
// Helper field for Chorus mode
CollAlias string
}
RawPost struct {
Id, Slug string
Title string
Content string
Views int64
Font string
Created time.Time
Updated time.Time
IsRTL sql.NullBool
Language sql.NullString
OwnerID int64
CollectionID sql.NullInt64
+ MediaFilesList []string
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 ""
}
p.Content = stripHTMLWithoutEscaping(p.Content)
// and Markdown
p.Content = stripmd.StripOptions(p.Content, stripmd.Options{SkipImages: true})
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)
}
func (p Post) SummaryHTML() template.HTML {
return template.HTML(p.Summary())
}
// 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 (c CollectionPostPage) DisplayMonetization() string {
if c.Collection == nil {
log.Info("CollectionPostPage.DisplayMonetization: c.Collection is nil")
return ""
}
return displayMonetization(c.Monetization, c.Collection.Alias)
}
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), "", app.cfg))
post.Images = extractImages(post.Content)
}
}
var silenced bool
if found {
silenced, err = app.db.IsUserSilenced(ownerID.Int64)
if err != nil {
log.Error("view post: %v", err)
}
}
// Check if post has been unpublished
if title == "" && 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
Silenced bool
}{
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
}
if !page.IsOwner && silenced {
return ErrPostNotFound
}
page.Silenced = silenced
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)
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)
}
silenced, err := app.db.IsUserSilenced(userID)
if err != nil {
log.Error("new post: %v", err)
}
if silenced {
return ErrUserSilenced
}
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.Title)) == "" && (p.Content == nil || strings.TrimSpace(*(p.Content)) == "") {
return ErrNoPublishableContent
}
if p.Content == nil {
c := ""
p.Content = &c
}
} 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
if accessToken != "" {
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
newPost.URL = newPost.CanonicalURL(app.cfg.App.Host)
// 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)
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
}
}
silenced, err := app.db.IsUserSilenced(userID)
if err != nil {
log.Error("existing post: %v", err)
}
if silenced {
return ErrUserSilenced
}
// 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
}
silenced, err := app.db.IsUserSilenced(ownerID)
if err != nil {
log.Error("add post: %v", err)
}
if silenced {
return ErrUserSilenced
}
// 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(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
}
silenced, err := app.db.IsUserSilenced(userID)
if err != nil {
log.Error("pin post: %v", err)
}
if silenced {
return ErrUserSilenced
}
// 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
}
collID = coll.ID
}
p, err := app.db.GetPost(vars["post"], collID)
if err != nil {
return err
}
if coll == nil && p.CollectionID.Valid {
// Collection post is getting fetched by post ID, not coll alias + post slug, so get coll info now.
coll, err = app.db.GetCollectionByID(p.CollectionID.Int64)
if err != nil {
return err
}
}
if coll != nil {
coll.hostName = app.cfg.App.Host
_, err = apiCheckCollectionPermissions(app, r, coll)
if err != nil {
return err
}
}
silenced, err := app.db.IsUserSilenced(p.OwnerID.Int64)
if err != nil {
log.Error("fetch post: %v", err)
}
if silenced {
return ErrPostNotFound
}
p.extractData()
accept := r.Header.Get("Accept")
if strings.Contains(accept, "application/activity+json") {
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(app)
po.Context = []interface{}{activitystreams.Namespace}
setCacheControl(w, apCacheTime)
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(hostName string) string {
if p.Collection == nil || p.Collection.Alias == "" {
return hostName + "/" + p.ID + ".md"
}
return p.Collection.CanonicalURL() + p.Slug.String
}
func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object {
cfg := app.cfg
var o *activitystreams.Object
if cfg.App.NotesOnly || strings.Index(p.Content, "\n\n") == -1 {
o = activitystreams.NewNoteObject()
} else {
o = activitystreams.NewArticleObject()
}
o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID
o.Published = p.Created
o.URL = p.CanonicalURL(cfg.App.Host)
o.AttributedTo = p.Collection.FederatedAccount()
o.CC = []string{
p.Collection.FederatedAccount() + "/followers",
}
o.Name = p.DisplayTitle()
p.augmentContent()
if p.HTMLContent == template.HTML("") {
p.formatContent(cfg, false, false)
p.augmentReadingDestination()
}
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 {
if cfg.App.Chorus {
tagBaseURL = fmt.Sprintf("%s/read/t/", p.Collection.hostName)
} 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,
})
}
}
if len(p.Images) > 0 {
for _, i := range p.Images {
o.Attachment = append(o.Attachment, activitystreams.NewImageAttachment(i))
}
}
// Find mentioned users
mentionedUsers := make(map[string]string)
stripper := bluemonday.StrictPolicy()
content := stripper.Sanitize(p.Content)
mentions := mentionReg.FindAllString(content, -1)
for _, handle := range mentions {
actorIRI, err := app.db.GetProfilePageFromHandle(app, handle)
if err != nil {
log.Info("Couldn't find user '%s' locally or remotely", handle)
continue
}
mentionedUsers[handle] = actorIRI
}
for handle, iri := range mentionedUsers {
o.CC = append(o.CC, iri)
o.Tag = append(o.Tag, activitystreams.Tag{Type: "Mention", HRef: iri, Name: handle})
}
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 == "" {
// Remove Markdown, so e.g. link URLs and image alt text don't make it into the slug
body = strings.TrimSpace(stripmd.StripOptions(body, stripmd.Options{SkipImages: true}))
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, updated time.Time
err := app.db.QueryRow("SELECT title, content, text_appearance, language, rtl, created, updated, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&title, &content, &font, &lang, &isRTL, &created, &updated, &ownerID)
switch {
case err == sql.ErrNoRows:
return &RawPost{Content: "", Found: false, Gone: false}
case err != nil:
log.Error("Unable to fetch getRawPost: %s", err)
return &RawPost{Content: "", Found: true, Gone: false}
}
return &RawPost{
Title: title,
Content: content,
Font: font,
Created: created,
Updated: updated,
IsRTL: isRTL,
Language: lang,
OwnerID: ownerID.Int64,
Found: true,
Gone: content == "" && title == "",
}
}
// 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, updated 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, updated, owner_id FROM posts WHERE slug = ? AND collection_id = 1", slug).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &updated, &ownerID)
} else {
err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, updated, 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, &updated, &ownerID)
}
switch {
case err == sql.ErrNoRows:
return &RawPost{Content: "", Found: false, Gone: false}
case err != nil:
log.Error("Unable to fetch getRawCollectionPost: %s", err)
return &RawPost{Content: "", Found: true, Gone: false}
}
return &RawPost{
Id: id,
Slug: slug,
Title: title,
Content: content,
Font: font,
Created: created,
Updated: updated,
IsRTL: isRTL,
Language: lang,
OwnerID: ownerID.Int64,
Found: true,
Gone: content == "" && title == "",
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
silenced, err := app.db.IsUserSilenced(c.OwnerID)
if err != nil {
log.Error("view collection post: %v", err)
}
// Check collection permissions
if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) {
return ErrPostNotFound
}
if c.IsProtected() && (u == nil || u.ID != c.OwnerID) {
if silenced {
return ErrPostNotFound
} else if !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 := NewCollectionObj(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 {
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: `<p class="msg">This page is missing.</p>
Are you sure it was ever here?`,
}
pp := po.processPost()
p = &pp
} else {
return err
}
}
// Check if the authenticated user is the post owner
p.IsOwner = u != nil && u.ID == p.OwnerID.Int64
p.Collection = coll
p.IsTopLevel = app.cfg.App.SingleUser
// Only allow a post owner or admin to view a post for silenced collections
if silenced && !p.IsOwner && (u == nil || !u.IsAdmin()) {
return ErrPostNotFound
}
// Check if post has been unpublished
if p.Content == "" && p.Title.String == "" {
return impart.HTTPError{http.StatusGone, "Post was unpublished."}
}
p.augmentContent()
// 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(app)
ap.Context = []interface{}{activitystreams.Namespace}
setCacheControl(w, apCacheTime)
return impart.RenderActivityJSON(w, ap, http.StatusOK)
} else {
p.extractData()
p.Content = strings.Replace(p.Content, "<!--more-->", "", 1)
// TODO: move this to function
p.formatContent(app.cfg, cr.isCollOwner, true)
tp := CollectionPostPage{
PublicPost: p,
StaticPage: pageForReq(app, r),
IsOwner: cr.isCollOwner,
IsCustomDomain: cr.isCustomDomain,
IsFound: postFound,
Silenced: silenced,
CollAlias: c.Alias,
}
tp.IsAdmin = u != nil && u.IsAdmin()
tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin)
tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll, p.IsOwner)
tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p)
tp.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
if !postFound {
w.WriteHeader(http.StatusNotFound)
}
postTmpl := "collection-post"
if app.cfg.App.Chorus {
postTmpl = "chorus-collection-post"
}
if err := templates[postTmpl].ExecuteTemplate(w, "post", tp); err != nil {
log.Error("Error in %s template: %v", postTmpl, 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")
}
func (rp *RawPost) Updated8601() string {
if rp.Updated.IsZero() {
return ""
}
return rp.Updated.Format("2006-01-02T15:04:05Z")
}
var imageURLRegex = regexp.MustCompile(`(?i)[^ ]+\.(gif|png|jpg|jpeg|image)$`)
func (p *Post) extractImages() {
p.Images = extractImages(p.Content)
}
func extractImages(content string) []string {
matches := extract.ExtractUrls(content)
urls := map[string]bool{}
for i := range matches {
uRaw := matches[i].Text
// Parse the extracted text so we can examine the path
u, err := url.Parse(uRaw)
if err != nil {
continue
}
// Ensure the path looks like it leads to an image file
if !imageURLRegex.MatchString(u.Path) {
continue
}
urls[uRaw] = true
}
resURLs := make([]string, 0)
for k := range urls {
resURLs = append(resURLs, k)
}
return resURLs
}
diff --git a/routes.go b/routes.go
index 00b6bd0..45867bf 100644
--- a/routes.go
+++ b/routes.go
@@ -1,237 +1,243 @@
/*
* Copyright © 2018-2021 Musing Studio 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 (
"net/http"
"net/url"
"path/filepath"
"strings"
"github.com/gorilla/csrf"
"github.com/gorilla/mux"
"github.com/writeas/go-webfinger"
"github.com/writeas/web-core/log"
"github.com/writefreely/go-nodeinfo"
)
// InitStaticRoutes adds routes for serving static files.
// TODO: this should just be a func, not method
func (app *App) InitStaticRoutes(r *mux.Router) {
// Handle static files
fs := http.FileServer(http.Dir(filepath.Join(app.cfg.Server.StaticParentDir, staticDir)))
fs = cacheControl(fs)
app.shttp = http.NewServeMux()
app.shttp.Handle("/", fs)
r.PathPrefix("/").Handler(fs)
}
// InitRoutes adds dynamic routes for the given mux.Router.
func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
// Create handler
handler := NewWFHandler(apper)
// Set up routes
hostSubroute := apper.App().cfg.App.Host[strings.Index(apper.App().cfg.App.Host, "://")+3:]
if apper.App().cfg.App.SingleUser {
hostSubroute = "{domain}"
} else {
if strings.HasPrefix(hostSubroute, "localhost") {
hostSubroute = "localhost"
}
}
if apper.App().cfg.App.SingleUser {
log.Info("Adding %s routes (single user)...", hostSubroute)
} else {
log.Info("Adding %s routes (multi-user)...", hostSubroute)
}
// Primary app routes
write := r.PathPrefix("/").Subrouter()
// Federation endpoint configurations
wf := webfinger.Default(wfResolver{apper.App().db, apper.App().cfg})
wf.NoTLSHandler = nil
// Federation endpoints
// host-meta
write.HandleFunc("/.well-known/host-meta", handler.Web(handleViewHostMeta, UserLevelReader))
// webfinger
write.HandleFunc(webfinger.WebFingerPath, handler.LogHandlerFunc(http.HandlerFunc(wf.Webfinger)))
// nodeinfo
niCfg := nodeInfoConfig(apper.App().db, apper.App().cfg)
ni := nodeinfo.NewService(*niCfg, nodeInfoResolver{apper.App().cfg, apper.App().db})
write.HandleFunc(nodeinfo.NodeInfoPath, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfoDiscover)))
write.HandleFunc(niCfg.InfoURL, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfo)))
// handle mentions
write.HandleFunc("/@/{handle}", handler.Web(handleViewMention, UserLevelReader))
configureSlackOauth(handler, write, apper.App())
configureWriteAsOauth(handler, write, apper.App())
configureGitlabOauth(handler, write, apper.App())
configureGenericOauth(handler, write, apper.App())
configureGiteaOauth(handler, write, apper.App())
// Set up dyamic page handlers
// Handle auth
auth := write.PathPrefix("/api/auth/").Subrouter()
if apper.App().cfg.App.OpenRegistration {
auth.HandleFunc("/signup", handler.All(apiSignup)).Methods("POST")
}
auth.HandleFunc("/login", handler.All(login)).Methods("POST")
auth.HandleFunc("/read", handler.WebErrors(handleWebCollectionUnlock, UserLevelNone)).Methods("POST")
auth.HandleFunc("/me", handler.All(handleAPILogout)).Methods("DELETE")
// Handle logged in user sections
me := write.PathPrefix("/me").Subrouter()
me.HandleFunc("/", handler.Redirect("/me", UserLevelUser))
me.HandleFunc("/c", handler.Redirect("/me/c/", UserLevelUser)).Methods("GET")
me.HandleFunc("/c/", handler.User(viewCollections)).Methods("GET")
me.HandleFunc("/c/{collection}", handler.User(viewEditCollection)).Methods("GET")
me.HandleFunc("/c/{collection}/stats", handler.User(viewStats)).Methods("GET")
me.Path("/delete").Handler(csrf.Protect(apper.App().keys.CSRFKey)(handler.User(handleUserDelete))).Methods("POST")
me.HandleFunc("/posts", handler.Redirect("/me/posts/", UserLevelUser)).Methods("GET")
me.HandleFunc("/posts/", handler.User(viewArticles)).Methods("GET")
me.HandleFunc("/posts/export.csv", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
me.HandleFunc("/posts/export.zip", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
me.HandleFunc("/posts/export.json", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET")
me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET")
me.HandleFunc("/import", handler.User(viewImport)).Methods("GET")
me.Path("/settings").Handler(csrf.Protect(apper.App().keys.CSRFKey)(handler.User(viewSettings))).Methods("GET")
me.HandleFunc("/invites", handler.User(handleViewUserInvites)).Methods("GET")
me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET")
+ if apper.App().cfg.App.AllowUploadMedia {
+ write.HandleFunc("/media/{author}/{slug}/{filename}", handler.All(handleGetFile)).Methods("GET")
+ }
+
write.HandleFunc("/api/me", handler.All(viewMeAPI)).Methods("GET")
apiMe := write.PathPrefix("/api/me/").Subrouter()
apiMe.HandleFunc("/", handler.All(viewMeAPI)).Methods("GET")
apiMe.HandleFunc("/posts", handler.UserWebAPI(viewMyPostsAPI)).Methods("GET")
apiMe.HandleFunc("/collections", handler.UserAPI(viewMyCollectionsAPI)).Methods("GET")
apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST")
apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST")
apiMe.HandleFunc("/invites", handler.User(handleCreateUserInvite)).Methods("POST")
apiMe.HandleFunc("/import", handler.User(handleImport)).Methods("POST")
apiMe.HandleFunc("/oauth/remove", handler.User(removeOauth)).Methods("POST")
// Sign up validation
write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST")
write.HandleFunc("/api/markdown", handler.All(handleRenderMarkdown)).Methods("POST")
instanceURL, _ := url.Parse(apper.App().Config().App.Host)
host := instanceURL.Host
// Handle collections
write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST")
apiColls := write.PathPrefix("/api/collections/").Subrouter()
apiColls.HandleFunc("/monetization-pointer", handler.PlainTextAPI(handleSPSPEndpoint)).Methods("GET")
apiColls.HandleFunc("/"+host, handler.AllReader(fetchCollection)).Methods("GET")
apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.AllReader(fetchCollection)).Methods("GET")
apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.All(existingCollection)).Methods("POST", "DELETE")
apiColls.HandleFunc("/{alias}/posts", handler.AllReader(fetchCollectionPosts)).Methods("GET")
apiColls.HandleFunc("/{alias}/posts", handler.All(newPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/posts/{post}", handler.AllReader(fetchPost)).Methods("GET")
apiColls.HandleFunc("/{alias}/posts/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/posts/{post}/splitcontent", handler.AllReader(handleGetSplitContent)).Methods("GET", "POST")
apiColls.HandleFunc("/{alias}/posts/{post}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET")
apiColls.HandleFunc("/{alias}/collect", handler.All(addPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/unpin", handler.All(pinPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/inbox", handler.All(handleFetchCollectionInbox)).Methods("POST")
apiColls.HandleFunc("/{alias}/outbox", handler.AllReader(handleFetchCollectionOutbox)).Methods("GET")
apiColls.HandleFunc("/{alias}/following", handler.AllReader(handleFetchCollectionFollowing)).Methods("GET")
apiColls.HandleFunc("/{alias}/followers", handler.AllReader(handleFetchCollectionFollowers)).Methods("GET")
// Handle posts
write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST")
posts := write.PathPrefix("/api/posts/").Subrouter()
posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.AllReader(fetchPost)).Methods("GET")
posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST", "PUT")
posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(deletePost)).Methods("DELETE")
posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET")
posts.HandleFunc("/claim", handler.All(addPost)).Methods("POST")
posts.HandleFunc("/disperse", handler.All(dispersePost)).Methods("POST")
write.HandleFunc("/auth/signup", handler.Web(handleWebSignup, UserLevelNoneRequired)).Methods("POST")
write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST")
write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET")
write.HandleFunc("/admin/monitor", handler.Admin(handleViewAdminMonitor)).Methods("GET")
write.HandleFunc("/admin/settings", handler.Admin(handleViewAdminSettings)).Methods("GET")
write.HandleFunc("/admin/users", handler.Admin(handleViewAdminUsers)).Methods("GET")
write.HandleFunc("/admin/user/{username}", handler.Admin(handleViewAdminUser)).Methods("GET")
write.HandleFunc("/admin/user/{username}/delete", handler.Admin(handleAdminDeleteUser)).Methods("POST")
write.HandleFunc("/admin/user/{username}/status", handler.Admin(handleAdminToggleUserStatus)).Methods("POST")
write.HandleFunc("/admin/user/{username}/passphrase", handler.Admin(handleAdminResetUserPass)).Methods("POST")
write.HandleFunc("/admin/pages", handler.Admin(handleViewAdminPages)).Methods("GET")
write.HandleFunc("/admin/page/{slug}", handler.Admin(handleViewAdminPage)).Methods("GET")
write.HandleFunc("/admin/update/config", handler.AdminApper(handleAdminUpdateConfig)).Methods("POST")
write.HandleFunc("/admin/update/{page}", handler.Admin(handleAdminUpdateSite)).Methods("POST")
write.HandleFunc("/admin/updates", handler.Admin(handleViewAdminUpdates)).Methods("GET")
// Handle special pages first
write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired))
write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired))
write.HandleFunc("/invite/{code:[a-zA-Z0-9]+}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET")
// TODO: show a reader-specific 404 page if the function is disabled
write.HandleFunc("/read", handler.Web(viewLocalTimeline, UserLevelReader))
RouteRead(handler, UserLevelReader, write.PathPrefix("/read").Subrouter())
draftEditPrefix := ""
if apper.App().cfg.App.SingleUser {
draftEditPrefix = "/d"
write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET")
} else {
write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET")
}
// All the existing stuff
write.HandleFunc(draftEditPrefix+"/{action}/edit", handler.Web(handleViewPad, UserLevelUser)).Methods("GET")
write.HandleFunc(draftEditPrefix+"/{action}/meta", handler.Web(handleViewMeta, UserLevelUser)).Methods("GET")
// Collections
if apper.App().cfg.App.SingleUser {
RouteCollections(handler, write.PathPrefix("/").Subrouter())
} else {
write.HandleFunc("/{prefix:[@~$!\\-+]}{collection}", handler.Web(handleViewCollection, UserLevelReader))
write.HandleFunc("/{collection}/", handler.Web(handleViewCollection, UserLevelReader))
RouteCollections(handler, write.PathPrefix("/{prefix:[@~$!\\-+]?}{collection}").Subrouter())
// Posts
}
write.HandleFunc(draftEditPrefix+"/{post}", handler.Web(handleViewPost, UserLevelOptional))
write.HandleFunc("/", handler.Web(handleViewHome, UserLevelOptional))
return r
}
func RouteCollections(handler *Handler, r *mux.Router) {
r.HandleFunc("/logout", handler.Web(handleLogOutCollection, UserLevelOptional))
r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader))
r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelReader))
r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader))
r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap))
r.HandleFunc("/feed/", handler.AllReader(ViewFeed))
r.HandleFunc("/{slug}", handler.CollectionPostOrStatic)
r.HandleFunc("/{slug}/edit", handler.Web(handleViewPad, UserLevelUser))
r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser))
+ r.HandleFunc("/{slug}/edit/meta/mediafile/{filename}", handler.Web(handleDeleteFile, UserLevelUser)).Methods("DELETE")
+ r.HandleFunc("/{slug}/edit/meta/mediafile/", handler.Web(handleUploadMedia, UserLevelUser)).Methods("POST")
r.HandleFunc("/{slug}/", handler.Web(handleCollectionPostRedirect, UserLevelReader)).Methods("GET")
}
func RouteRead(handler *Handler, readPerm UserLevelFunc, r *mux.Router) {
r.HandleFunc("/api/posts", handler.Web(viewLocalTimelineAPI, readPerm))
r.HandleFunc("/p/{page}", handler.Web(viewLocalTimeline, readPerm))
r.HandleFunc("/feed/", handler.Web(viewLocalTimelineFeed, readPerm))
r.HandleFunc("/t/{tag}", handler.Web(viewLocalTimeline, readPerm))
r.HandleFunc("/a/{post}", handler.Web(handlePostIDRedirect, readPerm))
r.HandleFunc("/{author}", handler.Web(viewLocalTimeline, readPerm))
r.HandleFunc("/", handler.Web(viewLocalTimeline, readPerm))
}
diff --git a/templates/edit-meta.tmpl b/templates/edit-meta.tmpl
index d3f93a8..d626472 100644
--- a/templates/edit-meta.tmpl
+++ b/templates/edit-meta.tmpl
@@ -1,375 +1,448 @@
{{define "edit-meta"}}<!DOCTYPE HTML>
<html>
<head>
<title>Edit metadata: {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}} &mdash; {{.SiteName}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style type="text/css">
dt {
width: 8em;
}
.error {
display: none;
}
.mono {
font-style: normal;
}
#set-now {
font-style: italic;
margin-left: 0.25rem;
}
.content-container h2 a {
font-size: .6em;
font-weight: normal;
margin-left: 1em;
}
.content-container h2 a:link, .content-container h2 a:visited {
color: blue;
}
.content-container h2 a:hover {
text-decoration: underline;
}
</style>
</head>
<body id="pad-sub" class="light">
<header id="tools">
<div id="clip">
<h1><a href="/me/c/" title="View blogs"><img class="ic-24dp" src="/img/ic_blogs_dark@2x.png" /></a></h1>
<nav id="target" class=""><ul>
<li>{{if .EditCollection}}<a href="{{.EditCollection.CanonicalURL}}">{{.EditCollection.Title}}</a>{{else}}<a>Draft</a>{{end}}</li>
</ul></nav>
</div>
<div id="belt">
<div class="tool if-room"><a href="{{if .EditCollection}}{{.EditCollection.CanonicalURL}}{{.Post.Slug}}/edit{{else}}/{{.Post.Id}}/edit{{end}}" title="Edit post" id="edit"><img class="ic-24dp" src="/img/ic_edit_dark@2x.png" /></a></div>
<div class="tool if-room room-2"><a href="#theme" title="Toggle theme" id="toggle-theme"><img class="ic-24dp" src="/img/ic_brightness_dark@2x.png" /></a></div>
<div class="tool if-room room-1"><a href="/me/posts/" title="View posts" id="view-posts"><img class="ic-24dp" src="/img/ic_list_dark@2x.png" /></a></div>
</div>
</header>
<div class="content-container tight">
<form action="/api/{{if .EditCollection}}collections/{{.EditCollection.Alias}}/{{end}}posts/{{.Post.Id}}" method="post" onsubmit="return updateMeta()">
<h2>Edit metadata: {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}} <a href="/{{if .EditCollection}}{{if not .SingleUser}}{{.EditCollection.Alias}}/{{end}}{{.Post.Slug}}{{else}}{{if .SingleUser}}d/{{end}}{{.Post.Id}}{{end}}">view post</a></h2>
{{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}}
<dl class="dl-horizontal">
{{if .EditCollection}}
<dt><label for="slug">Slug</label></dt>
<dd><input type="text" id="slug" name="slug" value="{{.Post.Slug}}" /></dd>
{{end}}
<dt><label for="lang">Language</label></dt>
<dd>
<select name="lang" id="lang" dir="auto" class="inputform">
<option value=""></option>
<option value="ab"{{if eq "ab" .Post.Language.String}} selected="selected"{{end}}>аҧсуа бызшәа, аҧсшәа</option>
<option value="aa"{{if eq "aa" .Post.Language.String}} selected="selected"{{end}}>Afaraf</option>
<option value="af"{{if eq "af" .Post.Language.String}} selected="selected"{{end}}>Afrikaans</option>
<option value="ak"{{if eq "ak" .Post.Language.String}} selected="selected"{{end}}>Akan</option>
<option value="sq"{{if eq "sq" .Post.Language.String}} selected="selected"{{end}}>Shqip</option>
<option value="am"{{if eq "am" .Post.Language.String}} selected="selected"{{end}}>አማርኛ</option>
<option dir="rtl" value="ar"{{if eq "ar" .Post.Language.String}} selected="selected"{{end}}>العربية</option>
<option value="an"{{if eq "an" .Post.Language.String}} selected="selected"{{end}}>aragonés</option>
<option value="hy"{{if eq "hy" .Post.Language.String}} selected="selected"{{end}}>Հայերեն</option>
<option value="as"{{if eq "as" .Post.Language.String}} selected="selected"{{end}}>অসমীয়া</option>
<option value="av"{{if eq "av" .Post.Language.String}} selected="selected"{{end}}>авар мацӀ, магӀарул мацӀ</option>
<option value="ae"{{if eq "ae" .Post.Language.String}} selected="selected"{{end}}>avesta</option>
<option value="ay"{{if eq "ay" .Post.Language.String}} selected="selected"{{end}}>aymar aru</option>
<option value="az"{{if eq "az" .Post.Language.String}} selected="selected"{{end}}>azərbaycan dili</option>
<option value="bm"{{if eq "bm" .Post.Language.String}} selected="selected"{{end}}>bamanankan</option>
<option value="ba"{{if eq "ba" .Post.Language.String}} selected="selected"{{end}}>башҡорт теле</option>
<option value="eu"{{if eq "eu" .Post.Language.String}} selected="selected"{{end}}>euskara, euskera</option>
<option value="be"{{if eq "be" .Post.Language.String}} selected="selected"{{end}}>беларуская мова</option>
<option value="bn"{{if eq "bn" .Post.Language.String}} selected="selected"{{end}}>বাংলা</option>
<option value="bh"{{if eq "bh" .Post.Language.String}} selected="selected"{{end}}>भोजपुरी</option>
<option value="bi"{{if eq "bi" .Post.Language.String}} selected="selected"{{end}}>Bislama</option>
<option value="bs"{{if eq "bs" .Post.Language.String}} selected="selected"{{end}}>bosanski jezik</option>
<option value="br"{{if eq "br" .Post.Language.String}} selected="selected"{{end}}>brezhoneg</option>
<option value="bg"{{if eq "bg" .Post.Language.String}} selected="selected"{{end}}>български език</option>
<option value="my"{{if eq "my" .Post.Language.String}} selected="selected"{{end}}>ဗမာစာ</option>
<option value="ca"{{if eq "ca" .Post.Language.String}} selected="selected"{{end}}>català</option>
<option value="ch"{{if eq "ch" .Post.Language.String}} selected="selected"{{end}}>Chamoru</option>
<option value="ce"{{if eq "ce" .Post.Language.String}} selected="selected"{{end}}>нохчийн мотт</option>
<option value="ny"{{if eq "ny" .Post.Language.String}} selected="selected"{{end}}>chiCheŵa, chinyanja</option>
<option value="zh"{{if eq "zh" .Post.Language.String}} selected="selected"{{end}}>中文 (Zhōngwén), 汉语, 漢語</option>
<option value="cv"{{if eq "cv" .Post.Language.String}} selected="selected"{{end}}>чӑваш чӗлхи</option>
<option value="kw"{{if eq "kw" .Post.Language.String}} selected="selected"{{end}}>Kernewek</option>
<option value="co"{{if eq "co" .Post.Language.String}} selected="selected"{{end}}>corsu, lingua corsa</option>
<option value="cr"{{if eq "cr" .Post.Language.String}} selected="selected"{{end}}>ᓀᐦᐃᔭᐍᐏᐣ</option>
<option value="hr"{{if eq "hr" .Post.Language.String}} selected="selected"{{end}}>hrvatski jezik</option>
<option value="cs"{{if eq "cs" .Post.Language.String}} selected="selected"{{end}}>čeština, český jazyk</option>
<option value="da"{{if eq "da" .Post.Language.String}} selected="selected"{{end}}>dansk</option>
<option dir="rtl" value="dv"{{if eq "dv" .Post.Language.String}} selected="selected"{{end}}>ދިވެހި</option>
<option value="nl"{{if eq "nl" .Post.Language.String}} selected="selected"{{end}}>Nederlands, Vlaams</option>
<option value="dz"{{if eq "dz" .Post.Language.String}} selected="selected"{{end}}>རྫོང་ཁ</option>
<option value="en"{{if eq "en" .Post.Language.String}} selected="selected"{{end}}>English</option>
<option value="eo"{{if eq "eo" .Post.Language.String}} selected="selected"{{end}}>Esperanto</option>
<option value="et"{{if eq "et" .Post.Language.String}} selected="selected"{{end}}>eesti, eesti keel</option>
<option value="ee"{{if eq "ee" .Post.Language.String}} selected="selected"{{end}}>Eʋegbe</option>
<option value="fo"{{if eq "fo" .Post.Language.String}} selected="selected"{{end}}>føroyskt</option>
<option value="fj"{{if eq "fj" .Post.Language.String}} selected="selected"{{end}}>vosa Vakaviti</option>
<option value="fi"{{if eq "fi" .Post.Language.String}} selected="selected"{{end}}>suomi, suomen kieli</option>
<option value="fr"{{if eq "fr" .Post.Language.String}} selected="selected"{{end}}>français, langue française</option>
<option value="ff"{{if eq "ff" .Post.Language.String}} selected="selected"{{end}}>Fulfulde, Pulaar, Pular</option>
<option value="gl"{{if eq "gl" .Post.Language.String}} selected="selected"{{end}}>Galego</option>
<option value="ka"{{if eq "ka" .Post.Language.String}} selected="selected"{{end}}>ქართული</option>
<option value="de"{{if eq "de" .Post.Language.String}} selected="selected"{{end}}>Deutsch</option>
<option value="el"{{if eq "el" .Post.Language.String}} selected="selected"{{end}}>ελληνικά</option>
<option value="gn"{{if eq "gn" .Post.Language.String}} selected="selected"{{end}}>Avañe'ẽ</option>
<option value="gu"{{if eq "gu" .Post.Language.String}} selected="selected"{{end}}>ગુજરાતી</option>
<option value="ht"{{if eq "ht" .Post.Language.String}} selected="selected"{{end}}>Kreyòl ayisyen</option>
<option dir="rtl" value="ha"{{if eq "ha" .Post.Language.String}} selected="selected"{{end}}>(Hausa) هَوُسَ</option>
<option dir="rtl" value="he"{{if eq "he" .Post.Language.String}} selected="selected"{{end}}>עברית</option>
<option value="hz"{{if eq "hz" .Post.Language.String}} selected="selected"{{end}}>Otjiherero</option>
<option value="hi"{{if eq "hi" .Post.Language.String}} selected="selected"{{end}}>हिन्दी, हिंदी</option>
<option value="ho"{{if eq "ho" .Post.Language.String}} selected="selected"{{end}}>Hiri Motu</option>
<option value="hu"{{if eq "hu" .Post.Language.String}} selected="selected"{{end}}>magyar</option>
<option value="ia"{{if eq "ia" .Post.Language.String}} selected="selected"{{end}}>Interlingua</option>
<option value="id"{{if eq "id" .Post.Language.String}} selected="selected"{{end}}>Bahasa Indonesia</option>
<option value="ie"{{if eq "ie" .Post.Language.String}} selected="selected"{{end}}>Interlingue</option>
<option value="ga"{{if eq "ga" .Post.Language.String}} selected="selected"{{end}}>Gaeilge</option>
<option value="ig"{{if eq "ig" .Post.Language.String}} selected="selected"{{end}}>Asụsụ Igbo</option>
<option value="ik"{{if eq "ik" .Post.Language.String}} selected="selected"{{end}}>Iñupiaq, Iñupiatun</option>
<option value="io"{{if eq "io" .Post.Language.String}} selected="selected"{{end}}>Ido</option>
<option value="is"{{if eq "is" .Post.Language.String}} selected="selected"{{end}}>Íslenska</option>
<option value="it"{{if eq "it" .Post.Language.String}} selected="selected"{{end}}>Italiano</option>
<option value="iu"{{if eq "iu" .Post.Language.String}} selected="selected"{{end}}>ᐃᓄᒃᑎᑐᑦ</option>
<option value="ja"{{if eq "ja" .Post.Language.String}} selected="selected"{{end}}>日本語 (にほんご)</option>
<option value="jv"{{if eq "jv" .Post.Language.String}} selected="selected"{{end}}>ꦧꦱꦗꦮ, Basa Jawa</option>
<option value="kl"{{if eq "kl" .Post.Language.String}} selected="selected"{{end}}>kalaallisut, kalaallit oqaasii</option>
<option value="kn"{{if eq "kn" .Post.Language.String}} selected="selected"{{end}}>ಕನ್ನಡ</option>
<option value="kr"{{if eq "kr" .Post.Language.String}} selected="selected"{{end}}>Kanuri</option>
<option value="ks"{{if eq "ks" .Post.Language.String}} selected="selected"{{end}}>कश्मीरी, كشميري‎</option>
<option value="kk"{{if eq "kk" .Post.Language.String}} selected="selected"{{end}}>қазақ тілі</option>
<option value="km"{{if eq "km" .Post.Language.String}} selected="selected"{{end}}>ខ្មែរ, ខេមរភាសា, ភាសាខ្មែរ</option>
<option value="ki"{{if eq "ki" .Post.Language.String}} selected="selected"{{end}}>Gĩkũyũ</option>
<option value="rw"{{if eq "rw" .Post.Language.String}} selected="selected"{{end}}>Ikinyarwanda</option>
<option value="ky"{{if eq "ky" .Post.Language.String}} selected="selected"{{end}}>Кыргызча, Кыргыз тили</option>
<option value="kv"{{if eq "kv" .Post.Language.String}} selected="selected"{{end}}>коми кыв</option>
<option value="kg"{{if eq "kg" .Post.Language.String}} selected="selected"{{end}}>Kikongo</option>
<option value="ko"{{if eq "ko" .Post.Language.String}} selected="selected"{{end}}>한국어</option>
<option value="ku"{{if eq "ku" .Post.Language.String}} selected="selected"{{end}}>Kurdî, كوردی‎</option>
<option value="kj"{{if eq "kj" .Post.Language.String}} selected="selected"{{end}}>Kuanyama</option>
<option value="la"{{if eq "la" .Post.Language.String}} selected="selected"{{end}}>latine, lingua latina</option>
<option value="lb"{{if eq "lb" .Post.Language.String}} selected="selected"{{end}}>Lëtzebuergesch</option>
<option value="lg"{{if eq "lg" .Post.Language.String}} selected="selected"{{end}}>Luganda</option>
<option value="li"{{if eq "li" .Post.Language.String}} selected="selected"{{end}}>Limburgs</option>
<option value="ln"{{if eq "ln" .Post.Language.String}} selected="selected"{{end}}>Lingála</option>
<option value="lo"{{if eq "lo" .Post.Language.String}} selected="selected"{{end}}>ພາສາລາວ</option>
<option value="lt"{{if eq "lt" .Post.Language.String}} selected="selected"{{end}}>lietuvių kalba</option>
<option value="lu"{{if eq "lu" .Post.Language.String}} selected="selected"{{end}}>Kiluba</option>
<option value="lv"{{if eq "lv" .Post.Language.String}} selected="selected"{{end}}>Latviešu Valoda</option>
<option value="gv"{{if eq "gv" .Post.Language.String}} selected="selected"{{end}}>Gaelg, Gailck</option>
<option value="mk"{{if eq "mk" .Post.Language.String}} selected="selected"{{end}}>македонски јазик</option>
<option value="mg"{{if eq "mg" .Post.Language.String}} selected="selected"{{end}}>fiteny malagasy</option>
<option value="ms"{{if eq "ms" .Post.Language.String}} selected="selected"{{end}}>Bahasa Melayu, بهاس ملايو‎</option>
<option value="ml"{{if eq "ml" .Post.Language.String}} selected="selected"{{end}}>മലയാളം</option>
<option value="mt"{{if eq "mt" .Post.Language.String}} selected="selected"{{end}}>Malti</option>
<option value="mi"{{if eq "mi" .Post.Language.String}} selected="selected"{{end}}>te reo Māori</option>
<option value="mr"{{if eq "mr" .Post.Language.String}} selected="selected"{{end}}>मराठी</option>
<option value="mh"{{if eq "mh" .Post.Language.String}} selected="selected"{{end}}>Kajin M̧ajeļ</option>
<option value="mn"{{if eq "mn" .Post.Language.String}} selected="selected"{{end}}>Монгол хэл</option>
<option value="na"{{if eq "na" .Post.Language.String}} selected="selected"{{end}}>Dorerin Naoero</option>
<option value="nv"{{if eq "nv" .Post.Language.String}} selected="selected"{{end}}>Diné bizaad</option>
<option value="nd"{{if eq "nd" .Post.Language.String}} selected="selected"{{end}}>isiNdebele</option>
<option value="ne"{{if eq "ne" .Post.Language.String}} selected="selected"{{end}}>नेपाली</option>
<option value="ng"{{if eq "ng" .Post.Language.String}} selected="selected"{{end}}>Owambo</option>
<option value="nb"{{if eq "nb" .Post.Language.String}} selected="selected"{{end}}>Norsk Bokmål</option>
<option value="nn"{{if eq "nn" .Post.Language.String}} selected="selected"{{end}}>Norsk Nynorsk</option>
<option value="no"{{if eq "no" .Post.Language.String}} selected="selected"{{end}}>Norsk</option>
<option value="ii"{{if eq "ii" .Post.Language.String}} selected="selected"{{end}}>ꆈꌠ꒿ Nuosuhxop</option>
<option value="nr"{{if eq "nr" .Post.Language.String}} selected="selected"{{end}}>isiNdebele</option>
<option value="oc"{{if eq "oc" .Post.Language.String}} selected="selected"{{end}}>occitan, lenga d'òc</option>
<option value="oj"{{if eq "oj" .Post.Language.String}} selected="selected"{{end}}>ᐊᓂᔑᓈᐯᒧᐎᓐ</option>
<option value="cu"{{if eq "cu" .Post.Language.String}} selected="selected"{{end}}>ѩзыкъ словѣньскъ</option>
<option value="om"{{if eq "om" .Post.Language.String}} selected="selected"{{end}}>Afaan Oromoo</option>
<option value="or"{{if eq "or" .Post.Language.String}} selected="selected"{{end}}>ଓଡ଼ିଆ</option>
<option value="os"{{if eq "os" .Post.Language.String}} selected="selected"{{end}}>ирон æвзаг</option>
<option value="pa"{{if eq "pa" .Post.Language.String}} selected="selected"{{end}}>ਪੰਜਾਬੀ</option>
<option value="pi"{{if eq "pi" .Post.Language.String}} selected="selected"{{end}}>पाऴि</option>
<option dir="rtl" value="fa"{{if eq "fa" .Post.Language.String}} selected="selected"{{end}}>فارسی</option>
<option value="pl"{{if eq "pl" .Post.Language.String}} selected="selected"{{end}}>Język Polski, Polszczyzna</option>
<option dir="rtl" value="ps"{{if eq "ps" .Post.Language.String}} selected="selected"{{end}}>پښتو</option>
<option value="pt"{{if eq "pt" .Post.Language.String}} selected="selected"{{end}}>Português</option>
<option value="qu"{{if eq "qu" .Post.Language.String}} selected="selected"{{end}}>Runa Simi, Kichwa</option>
<option value="rm"{{if eq "rm" .Post.Language.String}} selected="selected"{{end}}>Rumantsch Grischun</option>
<option value="rn"{{if eq "rn" .Post.Language.String}} selected="selected"{{end}}>Ikirundi</option>
<option value="ro"{{if eq "ro" .Post.Language.String}} selected="selected"{{end}}>Română</option>
<option value="ru"{{if eq "ru" .Post.Language.String}} selected="selected"{{end}}>Русский</option>
<option value="sa"{{if eq "sa" .Post.Language.String}} selected="selected"{{end}}>संस्कृतम्</option>
<option value="sc"{{if eq "sc" .Post.Language.String}} selected="selected"{{end}}>sardu</option>
<option value="sd"{{if eq "sd" .Post.Language.String}} selected="selected"{{end}}>सिन्धी, سنڌي، سندھی‎</option>
<option value="se"{{if eq "se" .Post.Language.String}} selected="selected"{{end}}>Davvisámegiella</option>
<option value="sm"{{if eq "sm" .Post.Language.String}} selected="selected"{{end}}>gagana fa'a Samoa</option>
<option value="sg"{{if eq "sg" .Post.Language.String}} selected="selected"{{end}}>yângâ tî sängö</option>
<option value="sr"{{if eq "sr" .Post.Language.String}} selected="selected"{{end}}>српски језик</option>
<option value="gd"{{if eq "gd" .Post.Language.String}} selected="selected"{{end}}>Gàidhlig</option>
<option value="sn"{{if eq "sn" .Post.Language.String}} selected="selected"{{end}}>chiShona</option>
<option value="si"{{if eq "si" .Post.Language.String}} selected="selected"{{end}}>සිංහල</option>
<option value="sk"{{if eq "sk" .Post.Language.String}} selected="selected"{{end}}>Slovenčina, Slovenský Jazyk</option>
<option value="sl"{{if eq "sl" .Post.Language.String}} selected="selected"{{end}}>Slovenski Jezik, Slovenščina</option>
<option value="so"{{if eq "so" .Post.Language.String}} selected="selected"{{end}}>Soomaaliga, af Soomaali</option>
<option value="st"{{if eq "st" .Post.Language.String}} selected="selected"{{end}}>Sesotho</option>
<option value="es"{{if eq "es" .Post.Language.String}} selected="selected"{{end}}>Español</option>
<option value="su"{{if eq "su" .Post.Language.String}} selected="selected"{{end}}>Basa Sunda</option>
<option value="sw"{{if eq "sw" .Post.Language.String}} selected="selected"{{end}}>Kiswahili</option>
<option value="ss"{{if eq "ss" .Post.Language.String}} selected="selected"{{end}}>SiSwati</option>
<option value="sv"{{if eq "sv" .Post.Language.String}} selected="selected"{{end}}>Svenska</option>
<option value="ta"{{if eq "ta" .Post.Language.String}} selected="selected"{{end}}>தமிழ்</option>
<option value="te"{{if eq "te" .Post.Language.String}} selected="selected"{{end}}>తెలుగు</option>
<option value="tg"{{if eq "tg" .Post.Language.String}} selected="selected"{{end}}>тоҷикӣ, toçikī, تاجیکی‎</option>
<option value="th"{{if eq "th" .Post.Language.String}} selected="selected"{{end}}>ไทย</option>
<option value="ti"{{if eq "ti" .Post.Language.String}} selected="selected"{{end}}>ትግርኛ</option>
<option value="bo"{{if eq "bo" .Post.Language.String}} selected="selected"{{end}}>བོད་ཡིག</option>
<option value="tk"{{if eq "tk" .Post.Language.String}} selected="selected"{{end}}>Türkmen, Түркмен</option>
<option value="tl"{{if eq "tl" .Post.Language.String}} selected="selected"{{end}}>Wikang Tagalog</option>
<option value="tn"{{if eq "tn" .Post.Language.String}} selected="selected"{{end}}>Setswana</option>
<option value="to"{{if eq "to" .Post.Language.String}} selected="selected"{{end}}>Faka Tonga</option>
<option value="tr"{{if eq "tr" .Post.Language.String}} selected="selected"{{end}}>Türkçe</option>
<option value="ts"{{if eq "ts" .Post.Language.String}} selected="selected"{{end}}>Xitsonga</option>
<option value="tt"{{if eq "tt" .Post.Language.String}} selected="selected"{{end}}>татар теле, tatar tele</option>
<option value="tw"{{if eq "tw" .Post.Language.String}} selected="selected"{{end}}>Twi</option>
<option value="ty"{{if eq "ty" .Post.Language.String}} selected="selected"{{end}}>Reo Tahiti</option>
<option value="ug"{{if eq "ug" .Post.Language.String}} selected="selected"{{end}}>ئۇيغۇرچە‎, Uyghurche</option>
<option value="uk"{{if eq "uk" .Post.Language.String}} selected="selected"{{end}}>Українська</option>
<option dir="rtl" value="ur"{{if eq "ur" .Post.Language.String}} selected="selected"{{end}}>اردو</option>
<option value="uz"{{if eq "uz" .Post.Language.String}} selected="selected"{{end}}>Oʻzbek, Ўзбек, أۇزبېك‎</option>
<option value="ve"{{if eq "ve" .Post.Language.String}} selected="selected"{{end}}>Tshivenḓa</option>
<option value="vi"{{if eq "vi" .Post.Language.String}} selected="selected"{{end}}>Tiếng Việt</option>
<option value="vo"{{if eq "vo" .Post.Language.String}} selected="selected"{{end}}>Volapük</option>
<option value="wa"{{if eq "wa" .Post.Language.String}} selected="selected"{{end}}>Walon</option>
<option value="cy"{{if eq "cy" .Post.Language.String}} selected="selected"{{end}}>Cymraeg</option>
<option value="wo"{{if eq "wo" .Post.Language.String}} selected="selected"{{end}}>Wollof</option>
<option value="fy"{{if eq "fy" .Post.Language.String}} selected="selected"{{end}}>Frysk</option>
<option value="xh"{{if eq "xh" .Post.Language.String}} selected="selected"{{end}}>isiXhosa</option>
<option dir="rtl" value="yi"{{if eq "yi" .Post.Language.String}} selected="selected"{{end}}>ייִדיש</option>
<option value="yo"{{if eq "yo" .Post.Language.String}} selected="selected"{{end}}>Yorùbá</option>
<option value="za"{{if eq "za" .Post.Language.String}} selected="selected"{{end}}>Saɯ cueŋƅ, Saw cuengh</option>
<option value="zu"{{if eq "zu" .Post.Language.String}} selected="selected"{{end}}>isiZulu</option>
</select>
</dd>
<dt><label for="rtl">Direction</label></dt>
<dd><input type="checkbox" id="rtl" name="rtl" {{if .Post.IsRTL.Bool}}checked="checked"{{end}} /><label for="rtl"> right-to-left</label></dd>
<dt><label for="created">Created</label></dt>
<dd>
<input type="text" id="created" name="created" value="{{.Post.UserFacingCreated}}" data-time="{{.Post.Created8601}}" placeholder="YYYY-MM-DD HH:MM:SS" maxlength="19" /> <span id="tz">UTC</span> <a href="#" id="set-now">now</a>
<p class="error" id="create-error">Date format should be: <span class="mono"><abbr title="The full year">YYYY</abbr>-<abbr title="The numeric month of the year, where January = 1, with a zero in front if less than 10">MM</abbr>-<abbr title="The day of the month, with a zero in front if less than 10">DD</abbr> <abbr title="The hour (00-23), with a zero in front if less than 10.">HH</abbr>:<abbr title="The minute of the hour (00-59), with a zero in front if less than 10.">MM</abbr>:<abbr title="The seconds (00-59), with a zero in front if less than 10.">SS</abbr></span></p>
</dd>
<dt>&nbsp;</dt><dd><input type="submit" value="Save changes" /></dd>
</dl>
<input type="hidden" name="web" value="true" />
</form>
+
+ <dt><label>Media Files List</label></dt></br>
+ <ul id="mediaFilesList"></ul>
+
+ <form id="uploadForm">
+ <input type="file" id="fileInput" />
+ <button type="button" onclick="uploadFile()">Upload</button>
+ </form>
+
</div>
<script src="/js/h.js"></script>
+
<script>
+ function uploadFile() {
+ const fileInput = document.getElementById('fileInput');
+ const file = fileInput.files[0];
+ if (!file) {
+ alert('Please select a file to upload.');
+ return;
+ }
+ const formData = new FormData();
+ formData.append('file', file);
+ const url = "meta/mediafile/"
+ fetch(url, {
+ method: 'POST',
+ body: formData,
+ }).then(response => response.json())
+ .then(data => {
+ alert('File uploaded successfully, path: /' + data.path);
+ location.reload();
+ }).catch(error => {
+ alert('An error occurred during file upload.');
+ });
+ }
+ </script>
+
+ <script>
+ function getFileNameFromPath(filePath) {
+ const parts = filePath.split("/");
+ return parts.pop();
+ }
+
+ function postDeleteMediafile(fileName) {
+ const url = "meta/mediafile/" + fileName;
+ fetch(url, {
+ method: 'DELETE',
+ headers: {},
+ })
+ .then(response => {
+ if (!response.ok) {
+ console.error(response);
+ } else {
+ location.reload();
+ }
+ });
+ }
+
+ const valueListElement = document.getElementById("mediaFilesList");
+ {{.Post.MediaFilesList}}.forEach((value) => {
+ const listItemElement = document.createElement("li");
+ listItemElement.textContent = value;
+ valueListElement.appendChild(listItemElement);
+ const buttonElement = document.createElement("button");
+ buttonElement.textContent = "Remove";
+ buttonElement.addEventListener("click", () => {
+ const confirmResult = confirm("Are you sure to remove: " + value + "?");
+ if (confirmResult) {
+ fileName = getFileNameFromPath(value)
+ postDeleteMediafile(fileName);
+ } else {}
+ });
+ listItemElement.appendChild(buttonElement);
+ });
+ </script>
+
+ <script>
function updateMeta() {
if ({{.Silenced}}) {
alert("Your account is silenced, so you can't edit posts.");
return
}
document.getElementById('create-error').style.display = 'none';
var $created = document.getElementById('created');
var dateStr = $created.value.trim();
var m = dateStr.match(/^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}( [0-9]{1,2}:[0-9]{1,2}(:[0-9]{1,2})?)?$/);
if (!m) {
document.getElementById('create-error').style.display = 'block';
return false;
}
// Break up the date and parse. This ensures cross-browser compatibility
var p = dateStr.split(/[^0-9]/);
var d = new Date(p[0], p[1]-1, p[2], p[3] ? p[3] : 0, p[4] ? p[4] : 0, p[5] ? p[5] : 0);
$created.value = d.getUTCFullYear() + '-' + ('0' + (d.getUTCMonth()+1)).slice(-2) + '-' + ('0' + d.getUTCDate()).slice(-2)+' '+('0'+d.getUTCHours()).slice(-2)+':'+('0'+d.getUTCMinutes()).slice(-2)+':'+('0'+d.getUTCSeconds()).slice(-2);
var $tz = document.getElementById('tz');
$tz.style.display = "inline";
var $submit = document.querySelector('input[type=submit]');
$submit.value = "Saving...";
$submit.disabled = true;
return true;
}
function dateToStr(d) {
return d.getFullYear() + '-' + ('0' + (d.getMonth()+1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2)+' '+('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2)+':'+('0'+d.getSeconds()).slice(-2);
}
function setLocalTime() {
var $created = document.getElementById('created');
var d = new Date($created.getAttribute('data-time'));
$created.value = dateToStr(d);
var $tz = document.getElementById('tz');
$tz.style.display = "none";
}
setLocalTime();
function setToNow() {
var $created = document.getElementById('created');
$created.value = dateToStr(new Date());
}
H.getEl('set-now').on('click', function(e) {
e.preventDefault();
setToNow();
});
function toggleTheme() {
var btns = Array.prototype.slice.call(document.getElementById('tools').querySelectorAll('a img'));
if (document.body.className == 'light') {
document.body.className = 'dark';
for (var i=0; i<btns.length; i++) {
btns[i].src = btns[i].src.replace('_dark@2x.png', '@2x.png');
}
} else {
document.body.className = 'light';
for (var i=0; i<btns.length; i++) {
btns[i].src = btns[i].src.replace('@2x.png', '_dark@2x.png');
}
}
H.set('padTheme', document.body.className);
}
if (H.get('padTheme', 'light') != 'light') {
toggleTheme();
}
var setButtonStates = function() {
if (!canPublish) {
$btnPublish.el.className = 'disabled';
return;
}
if ($writer.el.value.length === 0 || (draftDoc != 'lastDoc' && $writer.el.value == origDoc)) {
$btnPublish.el.className = 'disabled';
} else {
$btnPublish.el.className = '';
}
};
H.getEl('toggle-theme').on('click', function(e) {
e.preventDefault();
try {
var newTheme = 'light';
if (document.body.className == 'light') {
newTheme = 'dark';
}
} catch(e) {}
toggleTheme();
});
WebFontConfig = {
custom: { families: [ 'Lora:400,700:latin' ], urls: [ '/css/fonts.css' ] }
};
try {
(function() {
var wf=document.createElement('script');
wf.src = '/js/webfont.js';
wf.type='text/javascript';
wf.async='true';
var s=document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) {
// whatevs
}
</script>
<link href="/css/icons.css" rel="stylesheet">
</body>
</html>{{end}}

File Metadata

Mime Type
text/x-diff
Expires
Tue, Feb 10, 2:40 PM (17 h, 3 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3623031

Event Timeline