From 8a3daf7343c5e8a988d89992b9a4feda351ac563 Mon Sep 17 00:00:00 2001 From: Alborz Jafari Date: Sat, 29 Jul 2023 23:54:39 +0300 Subject: [PATCH 1/7] Add file uploading and file sharing functionality --- app.go | 1 + config/config.go | 6 ++ errors.go | 1 + pad.go | 206 ++++++++++++++++++++++++++++++++++++++- posts.go | 1 + routes.go | 6 ++ templates/edit-meta.tmpl | 73 ++++++++++++++ 7 files changed, 293 insertions(+), 1 deletion(-) diff --git a/app.go b/app.go index c7bdbb5..0ae5110 100644 --- a/app.go +++ b/app.go @@ -48,6 +48,7 @@ import ( const ( staticDir = "static" + mediaDir = "media" assumedTitleLen = 80 postsPerPage = 10 diff --git a/config/config.go b/config/config.go index 2065ddf..5213457 100644 --- a/config/config.go +++ b/config/config.go @@ -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,9 @@ 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"` } // Config holds the complete configuration for running a writefreely instance @@ -197,8 +201,10 @@ func New() *Config { SingleUser: true, MinUsernameLen: 3, MaxBlogs: 1, + MediaMaxSize: 10, Federation: true, PublicStats: true, + AllowUploadMedia: false, }, } c.UseMySQL(true) diff --git a/errors.go b/errors.go index f0d3099..ae9fd5f 100644 --- a/errors.go +++ b/errors.go @@ -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."} diff --git a/pad.go b/pad.go index 55dde70..f065cfb 100644 --- a/pad.go +++ b/pad.go @@ -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,121 @@ func handleViewMeta(app *App, w http.ResponseWriter, r *http.Request) error { 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 @@ -161,6 +161,7 @@ type ( Language sql.NullString OwnerID int64 CollectionID sql.NullInt64 + MediaFilesList []string Found bool Gone bool diff --git a/routes.go b/routes.go index 00b6bd0..45867bf 100644 --- a/routes.go +++ b/routes.go @@ -112,6 +112,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") @@ -223,6 +227,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") } 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 @@ -265,10 +265,83 @@ + +

+ + +
+ + +
+ + + + + +