Compare commits

...

12 Commits

Author SHA1 Message Date
Alborz Jafari 7285315d5e
Merge 7ee1aec3b7 into 038a80c25e 2024-04-19 14:19:35 +08:00
Matt Baer 038a80c25e
Merge pull request #893 from writefreely/consistent-reader-nav
Fix Admin and Invite links never showing on Reader nav
2024-04-17 12:44:28 -04:00
Matt Baer 9ece6682ef
Merge pull request #930 from tkngaejcpi/develop
support more image formats
2024-04-17 12:43:50 -04:00
Riley Chang 83ffea7fa0
support more image formats 2024-03-04 22:48:51 +08:00
Matt Baer e34a58d0ef Fix Admin and Invite links never showing on Reader nav 2024-02-20 10:02:14 -05:00
Alborz Jafari 7ee1aec3b7 Delete media files/directories along with corresponding posts/users 2023-08-31 22:12:06 +03:00
Alborz Jafari 6fbb51eb63 Add slug to drafts because uploading media needs slug 2023-08-30 22:07:35 +03:00
Alborz Jafari 7b537e3d4e Iterate over uploaded files in edit mode 2023-08-30 19:49:27 +03:00
Alborz Jafari eac4cbf390 Fix uploading file in draft mode with slug ID available 2023-08-30 18:37:07 +03:00
Alborz Jafari 9f8a1730e6 Add config for space limiting 2023-08-10 19:54:08 +03:00
Alborz Jafari ccde5ce6c9 Add space limit for uploading media files 2023-08-06 13:44:30 +03:00
Alborz Jafari 8a3daf7343 Add file uploading and file sharing functionality 2023-07-29 23:54:39 +03:00
10 changed files with 392 additions and 8 deletions

View File

@ -12,6 +12,7 @@ package writefreely
import (
"database/sql"
"path/filepath"
"fmt"
"html/template"
"net/http"
@ -19,6 +20,7 @@ import (
"strconv"
"strings"
"time"
"os"
"github.com/gorilla/mux"
"github.com/writeas/impart"
@ -345,10 +347,20 @@ func handleAdminDeleteUser(app *App, u *User, w http.ResponseWriter, r *http.Req
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not delete user account for '%s': %v", username, err)}
}
deleteMediaFilesOfUser(app, username)
_ = addSessionFlash(app, w, r, fmt.Sprintf("User \"%s\" was deleted successfully.", username), nil)
return impart.HTTPError{http.StatusFound, "/admin/users"}
}
func deleteMediaFilesOfUser(app *App, username string) {
mediaDirectoryPath := filepath.Join(app.cfg.Server.MediaParentDir, mediaDir,
username)
err := os.RemoveAll(mediaDirectoryPath)
if err != nil {
log.Error("Deleting media directory of %s failed: %v", username, err)
}
}
func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
username := vars["username"]

1
app.go
View File

@ -47,6 +47,7 @@ import (
const (
staticDir = "static"
mediaDir = "media"
assumedTitleLen = 80
postsPerPage = 10

View File

@ -43,6 +43,7 @@ type (
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"`
@ -168,6 +169,10 @@ type (
// 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"`
TotalMediaSpace int64 `ini:"total_media_size"`
}
EmailCfg struct {
@ -203,8 +208,11 @@ func New() *Config {
SingleUser: true,
MinUsernameLen: 3,
MaxBlogs: 1,
MediaMaxSize: 10,
TotalMediaSpace: 100,
Federation: true,
PublicStats: true,
AllowUploadMedia: false,
},
}
c.UseMySQL(true)

View File

@ -670,7 +670,12 @@ func (db *datastore) CreatePost(userID, collID int64, post *SubmittedPost) (*Pos
ownerCollID := sql.NullInt64{
Valid: false,
}
slug := sql.NullString{"", false}
slugString := *post.Slug
slugValid := true
if slugString == "" {
slugValid = false
}
slug := sql.NullString{slugString, slugValid}
// If an alias was supplied, we'll add this to the collection as well.
if userID > 0 {

View File

@ -43,6 +43,7 @@ var (
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."}

246
pad.go
View File

@ -11,9 +11,18 @@
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"
@ -120,6 +129,89 @@ func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error {
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"]
@ -185,9 +277,163 @@ func handleViewMeta(app *App, w http.ResponseWriter, r *http.Request) error {
return ErrPostNotFound
}
appData.Flashes, _ = getSessionFlashes(app, w, r, nil)
user := getUserSession(app, r)
if slug == "" {
slug, _ = getSlugFromActionId(app, action)
}
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
}

View File

@ -13,6 +13,8 @@ package writefreely
import (
"database/sql"
"encoding/json"
"path/filepath"
"errors"
"fmt"
"github.com/writefreely/writefreely/spam"
"html/template"
@ -21,6 +23,7 @@ import (
"regexp"
"strings"
"time"
"os"
"github.com/gorilla/mux"
"github.com/guregu/null"
@ -163,6 +166,7 @@ type (
Language sql.NullString
OwnerID int64
CollectionID sql.NullInt64
MediaFilesList []string
Found bool
Gone bool
@ -650,6 +654,8 @@ func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
}
collID = coll.ID
}
slug := getSlugFromPost(*p.Title, *p.Content, p.Language.String)
p.Slug = &slug
// TODO: return PublicPost from createPost
newPost.Post, err = app.db.CreatePost(userID, collID, p)
}
@ -821,6 +827,13 @@ func deletePost(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
friendlyID := vars["post"]
editToken := r.FormValue("token")
var err error
user := getUserSession(app, r)
slug, err := getSlugFromActionId(app, friendlyID)
if err == nil {
deleteMediaFilesOfPost(app, user.Username, slug)
}
var ownerID int64
var u *User
@ -834,7 +847,6 @@ func deletePost(app *App, w http.ResponseWriter, r *http.Request) error {
var res sql.Result
var t *sql.Tx
var err error
var collID sql.NullInt64
var coll *Collection
var pp *PublicPost
@ -933,6 +945,15 @@ func deletePost(app *App, w http.ResponseWriter, r *http.Request) error {
return impart.HTTPError{Status: http.StatusNoContent}
}
func deleteMediaFilesOfPost(app *App, username, slug string) {
mediaDirectoryPath := filepath.Join(app.cfg.Server.MediaParentDir, mediaDir,
username, slug)
err := os.RemoveAll(mediaDirectoryPath)
if err != nil {
log.Error("Deleting media directory of %s failed: %v", username, err)
}
}
// addPost associates a post with the authenticated user.
func addPost(app *App, w http.ResponseWriter, r *http.Request) error {
var ownerID int64
@ -1325,6 +1346,18 @@ func (p *SubmittedPost) isFontValid() bool {
return valid
}
func getSlugFromActionId(app *App, actionID string) (string, error) {
var slug string
err := app.db.QueryRow("SELECT slug FROM posts WHERE id = ?", actionID).Scan(&slug)
switch {
case err == sql.ErrNoRows:
return "", errors.New("Post not found")
case err != nil:
return "", errors.New("Unable to fetch post")
}
return slug, nil
}
func getRawPost(app *App, friendlyID string) *RawPost {
var content, font, title string
var isRTL sql.NullBool
@ -1669,7 +1702,7 @@ func (rp *RawPost) Updated8601() string {
return rp.Updated.Format("2006-01-02T15:04:05Z")
}
var imageURLRegex = regexp.MustCompile(`(?i)[^ ]+\.(gif|png|jpg|jpeg|image)$`)
var imageURLRegex = regexp.MustCompile(`(?i)[^ ]+\.(gif|png|jpg|jpeg|avif|avifs|webp|jxl|image)$`)
func (p *Post) extractImages() {
p.Images = extractImages(p.Content)

View File

@ -229,11 +229,9 @@ func showLocalTimeline(app *App, w http.ResponseWriter, r *http.Request, page in
TotalPages: ttlPages,
SelTopic: tag,
}
if app.cfg.App.Chorus {
u := getUserSession(app, r)
d.IsAdmin = u != nil && u.IsAdmin()
d.CanInvite = canUserInvite(app.cfg, d.IsAdmin)
}
u := getUserSession(app, r)
d.IsAdmin = u != nil && u.IsAdmin()
d.CanInvite = canUserInvite(app.cfg, d.IsAdmin)
c, err := getReaderSection(app)
if err != nil {
return err

View File

@ -113,6 +113,10 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
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")
@ -203,6 +207,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
// 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")
write.HandleFunc(draftEditPrefix+"/{action}/meta/mediafile/", handler.Web(handleUploadMedia, UserLevelUser)).Methods("POST")
// Collections
if apper.App().cfg.App.SingleUser {
RouteCollections(handler, write.PathPrefix("/").Subrouter())
@ -233,6 +238,8 @@ func RouteCollections(handler *Handler, r *mux.Router) {
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")
}

View File

@ -265,10 +265,83 @@
</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.");