|
|
|
/*
|
|
|
|
* 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 calculateDirectoryTotalSize(dirPath string) (int64, error) {
|
|
|
|
var totalSize int64
|
|
|
|
|
|
|
|
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
if !info.IsDir() {
|
|
|
|
totalSize += info.Size()
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
})
|
|
|
|
|
|
|
|
return totalSize, err
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
fileSize := r.ContentLength
|
|
|
|
|
|
|
|
if err := okToEdit(app, w, r); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
vars := mux.Vars(r)
|
|
|
|
slug := vars["slug"]
|
|
|
|
|
|
|
|
if slug == "" {
|
|
|
|
actionId := vars["action"]
|
|
|
|
if actionId == "" {
|
|
|
|
return ErrPostNotFound
|
|
|
|
}
|
|
|
|
var err error
|
|
|
|
slug, err = getSlugFromActionId(app, actionId)
|
|
|
|
if slug == "" || err != nil {
|
|
|
|
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()
|
|
|
|
user := getUserSession(app, r)
|
|
|
|
mediaDirectoryPath := filepath.Join(app.cfg.Server.MediaParentDir, mediaDir,
|
|
|
|
user.Username, slug)
|
|
|
|
err = os.MkdirAll(mediaDirectoryPath, 0755)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
totalSize, err := calculateDirectoryTotalSize(mediaDirectoryPath)
|
|
|
|
totalMediaSpace := app.cfg.App.TotalMediaSpace * 1024 * 1024
|
|
|
|
if totalSize + fileSize > totalMediaSpace {
|
|
|
|
errMsg := fmt.Sprintf("Your upload space limit has been exceeded. Your limit is: %d MB",
|
|
|
|
app.cfg.App.TotalMediaSpace)
|
|
|
|
http.Error(w, errMsg, http.StatusBadRequest)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|