diff --git a/internal/db/gist.go b/internal/db/gist.go index 1c64817..06af1be 100644 --- a/internal/db/gist.go +++ b/internal/db/gist.go @@ -487,8 +487,14 @@ func (gist *Gist) AddAndCommitFiles(files *[]FileDTO) error { } for _, file := range *files { - if err := git.SetFileContent(gist.Uuid, file.Filename, file.Content); err != nil { - return err + if file.SourcePath != "" { // if it's an uploaded file + if err := git.MoveFileToRepository(gist.Uuid, file.Filename, file.SourcePath); err != nil { + return err + } + } else { // else it's a text editor file + if err := git.SetFileContent(gist.Uuid, file.Filename, file.Content); err != nil { + return err + } } } @@ -546,19 +552,28 @@ func (gist *Gist) UpdatePreviewAndCount(withTimestampUpdate bool) error { gist.Preview = "" gist.PreviewFilename = "" } else { - file, err := gist.File("HEAD", filesStr[0], true) - if err != nil { - return err - } + for _, fileStr := range filesStr { + file, err := gist.File("HEAD", fileStr, true) + if err != nil { + return err + } + if file == nil { + continue + } + gist.Preview = "" + gist.PreviewFilename = file.Filename - split := strings.Split(file.Content, "\n") - if len(split) > 10 { - gist.Preview = strings.Join(split[:10], "\n") - } else { - gist.Preview = file.Content - } + if !file.MimeType.CanBeEdited() { + continue + } - gist.PreviewFilename = file.Filename + split := strings.Split(file.Content, "\n") + if len(split) > 10 { + gist.Preview = strings.Join(split[:10], "\n") + } else { + gist.Preview = file.Content + } + } } if withTimestampUpdate { @@ -721,9 +736,10 @@ type VisibilityDTO struct { } type FileDTO struct { - Filename string `validate:"excludes=\x2f,excludes=\x5c,max=255"` - Content string `validate:"required"` - Binary bool + Filename string `validate:"excludes=\x2f,excludes=\x5c,max=255"` + Content string + Binary bool + SourcePath string // Path to uploaded file, used instead of Content when present } func (dto *GistDTO) ToGist() *Gist { diff --git a/internal/git/commands.go b/internal/git/commands.go index 249443e..d5abe26 100644 --- a/internal/git/commands.go +++ b/internal/git/commands.go @@ -380,6 +380,17 @@ func SetFileContent(gistTmpId string, filename string, content string) error { return os.WriteFile(filepath.Join(repositoryPath, filename), []byte(content), 0644) } +func MoveFileToRepository(gistTmpId string, filename string, sourcePath string) error { + repositoryPath := TmpRepositoryPath(gistTmpId) + destPath := filepath.Join(repositoryPath, filename) + + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return err + } + + return os.Rename(sourcePath, destPath) +} + func AddAll(gistTmpId string) error { tmpPath := TmpRepositoryPath(gistTmpId) diff --git a/internal/i18n/locales/en-US.yml b/internal/i18n/locales/en-US.yml index 7d372e2..0e0847d 100644 --- a/internal/i18n/locales/en-US.yml +++ b/internal/i18n/locales/en-US.yml @@ -28,6 +28,7 @@ gist.file-binary-edit: This file is binary. gist.watch-full-file: View the full file. gist.file-not-valid: This file is not a valid CSV file. gist.no-content: No files found +gist.preview-non-available: Preview not available gist.new.new_gist: New gist gist.new.title: Title @@ -48,6 +49,8 @@ gist.new.create-private-button: Create private gist gist.new.preview: Preview gist.new.create-a-new-gist: Create a new gist gist.new.topics: Topics (separate with spaces) +gist.new.drop-files: Drop files here or click to upload +gist.new.any-file-type: Upload any file type gist.edit.editing: Editing gist.edit.edit-gist: Edit %s @@ -218,6 +221,8 @@ error.cannot-bind-data: Cannot bind data error.invalid-number: Invalid number error.invalid-character-unescaped: Invalid character unescaped error.not-in-mfa-session: User is not in a MFA session +error.no-file-uploaded: No file uploaded +error.cannot-open-file: Cannot open uploaded file header.menu.all: All header.menu.new: New diff --git a/internal/web/handlers/gist/create.go b/internal/web/handlers/gist/create.go index 0d6ceaf..35cbf4e 100644 --- a/internal/web/handlers/gist/create.go +++ b/internal/web/handlers/gist/create.go @@ -2,11 +2,15 @@ package gist import ( "net/url" + "os" + "path/filepath" "strconv" "strings" "github.com/google/uuid" + "github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/db" + "github.com/thomiceli/opengist/internal/git" "github.com/thomiceli/opengist/internal/i18n" "github.com/thomiceli/opengist/internal/validator" "github.com/thomiceli/opengist/internal/web/context" @@ -44,10 +48,16 @@ func ProcessCreate(ctx *context.Context) error { dto.Files = make([]db.FileDTO, 0) fileCounter := 0 - for i := 0; i < len(ctx.Request().PostForm["content"]); i++ { - name := ctx.Request().PostForm["name"][i] - content := ctx.Request().PostForm["content"][i] + names := ctx.Request().PostForm["name"] + contents := ctx.Request().PostForm["content"] + + // Process files from text editors + for i, content := range contents { + if content == "" { + continue + } + name := names[i] if name == "" { fileCounter += 1 name = "gistfile" + strconv.Itoa(fileCounter) + ".txt" @@ -59,10 +69,57 @@ func ProcessCreate(ctx *context.Context) error { } dto.Files = append(dto.Files, db.FileDTO{ - Filename: strings.Trim(name, " "), + Filename: strings.TrimSpace(name), Content: escapedValue, }) } + + // Process uploaded files from UUID arrays + fileUUIDs := ctx.Request().PostForm["uploadedfile_uuid"] + fileFilenames := ctx.Request().PostForm["uploadedfile_filename"] + if len(fileUUIDs) == len(fileFilenames) { + for i, fileUUID := range fileUUIDs { + filePath := filepath.Join(filepath.Join(config.GetHomeDir(), "uploads"), fileUUID) + + if _, err := os.Stat(filePath); err != nil { + continue + } + + dto.Files = append(dto.Files, db.FileDTO{ + Filename: fileFilenames[i], + SourcePath: filePath, + Content: "", // Empty since we're using SourcePath + }) + } + } + + // Process binary file operations (edit mode) + binaryOldNames := ctx.Request().PostForm["binary_old_name"] + binaryNewNames := ctx.Request().PostForm["binary_new_name"] + if len(binaryOldNames) == len(binaryNewNames) { + for i, oldName := range binaryOldNames { + newName := binaryNewNames[i] + + if newName == "" { // deletion + continue + } + + if !isCreate { + gistOld := ctx.GetData("gist").(*db.Gist) + + fileContent, _, err := git.GetFileContent(gistOld.User.Username, gistOld.Uuid, "HEAD", oldName, false) + if err != nil { + continue + } + + dto.Files = append(dto.Files, db.FileDTO{ + Filename: newName, + Content: fileContent, + Binary: true, + }) + } + } + } ctx.SetData("dto", dto) err = ctx.Validate(dto) @@ -101,24 +158,13 @@ func ProcessCreate(ctx *context.Context) error { } if gist.Title == "" { - if ctx.Request().PostForm["name"][0] == "" { + if dto.Files[0].Filename == "" { gist.Title = "gist:" + gist.Uuid } else { - gist.Title = ctx.Request().PostForm["name"][0] + gist.Title = dto.Files[0].Filename } } - if len(dto.Files) > 0 { - split := strings.Split(dto.Files[0].Content, "\n") - if len(split) > 10 { - gist.Preview = strings.Join(split[:10], "\n") - } else { - gist.Preview = dto.Files[0].Content - } - - gist.PreviewFilename = dto.Files[0].Filename - } - if err = gist.InitRepository(); err != nil { return ctx.ErrorRes(500, "Error creating the repository", err) } @@ -139,6 +185,9 @@ func ProcessCreate(ctx *context.Context) error { gist.AddInIndex() gist.UpdateLanguages() + if err = gist.UpdatePreviewAndCount(true); err != nil { + return ctx.ErrorRes(500, "Error updating preview and count", err) + } return ctx.RedirectTo("/" + user.Username + "/" + gist.Identifier()) } diff --git a/internal/web/handlers/gist/upload.go b/internal/web/handlers/gist/upload.go new file mode 100644 index 0000000..abd8b40 --- /dev/null +++ b/internal/web/handlers/gist/upload.go @@ -0,0 +1,77 @@ +package gist + +import ( + "io" + "os" + "path/filepath" + + "github.com/google/uuid" + "github.com/thomiceli/opengist/internal/config" + "github.com/thomiceli/opengist/internal/web/context" +) + +func Upload(ctx *context.Context) error { + err := ctx.Request().ParseMultipartForm(32 << 20) // 32 MB max + if err != nil { + return ctx.ErrorRes(400, ctx.Tr("error.bad-request"), err) + } + + fileHeader, err := ctx.FormFile("file") + if err != nil { + return ctx.ErrorRes(400, ctx.Tr("error.no-file-uploaded"), err) + } + + file, err := fileHeader.Open() + if err != nil { + return ctx.ErrorRes(400, ctx.Tr("error.cannot-open-file"), err) + } + defer file.Close() + + fileUUID, err := uuid.NewRandom() + if err != nil { + return ctx.ErrorRes(500, "Error generating UUID", err) + } + + uploadsDir := filepath.Join(config.GetHomeDir(), "uploads") + if err := os.MkdirAll(uploadsDir, 0755); err != nil { + return ctx.ErrorRes(500, "Error creating uploads directory", err) + } + + filename := fileUUID.String() + filePath := filepath.Join(uploadsDir, filename) + + destFile, err := os.Create(filePath) + if err != nil { + return ctx.ErrorRes(500, "Error creating file", err) + } + defer destFile.Close() + + if _, err := io.Copy(destFile, file); err != nil { + return ctx.ErrorRes(500, "Error saving file", err) + } + + return ctx.JSON(200, map[string]string{ + "uuid": filename, + "filename": fileHeader.Filename, + }) +} + +func DeleteUpload(ctx *context.Context) error { + uuid := ctx.Param("uuid") + if uuid == "" { + return ctx.ErrorRes(400, ctx.Tr("error.bad-request"), nil) + } + + uploadsDir := filepath.Join(config.GetHomeDir(), "uploads") + filePath := filepath.Join(uploadsDir, uuid) + + if _, err := os.Stat(filePath); err == nil { + if err := os.Remove(filePath); err != nil { + return ctx.ErrorRes(500, "Error deleting file", err) + } + } + + return ctx.JSON(200, map[string]string{ + "status": "deleted", + }) +} diff --git a/internal/web/server/router.go b/internal/web/server/router.go index 9bd513f..82d929a 100644 --- a/internal/web/server/router.go +++ b/internal/web/server/router.go @@ -29,6 +29,8 @@ func (s *Server) registerRoutes() { r.GET("/", gist.Create, logged) r.POST("/", gist.ProcessCreate, logged) r.POST("/preview", gist.Preview, logged) + r.POST("/upload", gist.Upload, logged) + r.DELETE("/upload/:uuid", gist.DeleteUpload, logged) r.GET("/healthcheck", health.Healthcheck) diff --git a/internal/web/test/gist_test.go b/internal/web/test/gist_test.go index d878955..2be379d 100644 --- a/internal/web/test/gist_test.go +++ b/internal/web/test/gist_test.go @@ -66,7 +66,7 @@ func TestGists(t *testing.T) { Content: []string{"", "yeah\ncool", "yeah\ncool gist actually"}, Topics: "", } - err = s.Request("POST", "/", gist2, 400) + err = s.Request("POST", "/", gist2, 302) require.NoError(t, err) gist3 := db.GistDTO{ @@ -82,7 +82,7 @@ func TestGists(t *testing.T) { err = s.Request("POST", "/", gist3, 302) require.NoError(t, err) - gist3db, err := db.GetGistByID("2") + gist3db, err := db.GetGistByID("3") require.NoError(t, err) gist3files, err := git.GetFilesOfRepository(gist3db.User.Username, gist3db.Uuid, "HEAD") diff --git a/public/editor.ts b/public/editor.ts index 2833590..638f4ef 100644 --- a/public/editor.ts +++ b/public/editor.ts @@ -117,7 +117,11 @@ document.addEventListener("DOMContentLoaded", () => { let deleteBtns = dom.querySelector("button.delete-file"); if (deleteBtns !== null) { deleteBtns.onclick = () => { - editorsjs.splice(editorsjs.indexOf(editor), 1); + // For both text and binary files, just remove from DOM + if (!dom.hasAttribute('data-binary-original-name')) { + // Only remove from editors array for text files + editorsjs.splice(editorsjs.indexOf(editor), 1); + } dom.remove(); checkForFirstDeleteButton(); }; @@ -200,20 +204,23 @@ document.addEventListener("DOMContentLoaded", () => { if (formFileContent !== null) { let currEditor = newEditor(el, el.querySelector(".form-filecontent")!.value); editorsjs.push(currEditor); + } else if (el.hasAttribute('data-binary-original-name')) { + // For binary files, just set up the delete button + let deleteBtn = el.querySelector("button.delete-file"); + if (deleteBtn) { + deleteBtn.onclick = () => { + el.remove(); + checkForFirstDeleteButton(); + }; + } } }); checkForFirstDeleteButton(); document.getElementById("add-file")!.onclick = () => { - let newEditorDom = firstEditordom.cloneNode(true) as HTMLElement; - - // reset the filename of the new cloned element - newEditorDom.querySelector('input[name="name"]')!.value = ""; - - // removing the previous codemirror editor - let newEditorDomCM = newEditorDom.querySelector(".cm-editor"); - newEditorDomCM!.remove(); + const template = document.getElementById("editor-template")!; + const newEditorDom = template.firstElementChild!.cloneNode(true) as HTMLElement; // creating the new codemirror editor and append it in the editor div editorsjs.push(newEditor(newEditorDom)); @@ -223,9 +230,56 @@ document.addEventListener("DOMContentLoaded", () => { document.querySelector("form#create")!.onsubmit = () => { let j = 0; - document.querySelectorAll(".form-filecontent").forEach((e) => { - e.value = encodeURIComponent(editorsjs[j++].state.doc.toString()); + document.querySelectorAll(".form-filecontent").forEach((el) => { + if (j < editorsjs.length) { + el.value = encodeURIComponent(editorsjs[j++].state.doc.toString()); + } }); + + const fileInput = document.getElementById("file-upload") as HTMLInputElement; + if (fileInput) { + fileInput.remove(); + } + + const form = document.querySelector("form#create")!; + + uploadedFileUUIDs.forEach((fileData) => { + const uuidInput = document.createElement('input'); + uuidInput.type = 'hidden'; + uuidInput.name = 'uploadedfile_uuid'; + uuidInput.value = fileData.uuid; + form.appendChild(uuidInput); + + const filenameInput = document.createElement('input'); + filenameInput.type = 'hidden'; + filenameInput.name = 'uploadedfile_filename'; + filenameInput.value = fileData.filename; + form.appendChild(filenameInput); + }); + + const binaryFiles = document.querySelectorAll('[data-binary-original-name]'); + binaryFiles.forEach((fileDiv) => { + const originalName = fileDiv.getAttribute('data-binary-original-name'); + const fileNameInput = fileDiv.querySelector('.form-filename') as HTMLInputElement; + + if (fileNameInput) { + fileNameInput.removeAttribute('name'); + } + + const oldNameInput = document.createElement('input'); + oldNameInput.type = 'hidden'; + oldNameInput.name = 'binary_old_name'; + oldNameInput.value = originalName || ''; + form.appendChild(oldNameInput); + + const newNameInput = document.createElement('input'); + newNameInput.type = 'hidden'; + newNameInput.name = 'binary_new_name'; + newNameInput.value = fileNameInput?.value || ''; + form.appendChild(newNameInput); + }); + + window.onbeforeunload = null; }; document.getElementById('gist-metadata-btn')!.onclick = (el) => { @@ -242,16 +296,22 @@ document.addEventListener("DOMContentLoaded", () => { } function checkForFirstDeleteButton() { - let deleteBtn = editorsParentdom.querySelector("button.delete-file")!; - if (editorsjs.length === 1) { - deleteBtn.classList.add("hidden"); - deleteBtn.previousElementSibling.classList.remove("rounded-l-md"); - deleteBtn.previousElementSibling.classList.add("rounded-md"); - } else { - deleteBtn.classList.remove("hidden"); - deleteBtn.previousElementSibling.classList.add("rounded-l-md"); - deleteBtn.previousElementSibling.classList.remove("rounded-md"); - } + // Count total files (both text and binary) + const totalFiles = editorsParentdom.querySelectorAll('.editor').length; + + // Hide/show all delete buttons based on total file count + const deleteButtons = editorsParentdom.querySelectorAll("button.delete-file"); + deleteButtons.forEach(deleteBtn => { + if (totalFiles <= 1) { + deleteBtn.classList.add("hidden"); + deleteBtn.previousElementSibling?.classList.remove("rounded-l-md"); + deleteBtn.previousElementSibling?.classList.add("rounded-md"); + } else { + deleteBtn.classList.remove("hidden"); + deleteBtn.previousElementSibling?.classList.add("rounded-l-md"); + deleteBtn.previousElementSibling?.classList.remove("rounded-md"); + } + }); } function showDeleteButton(editorDom: HTMLElement) { @@ -262,7 +322,140 @@ document.addEventListener("DOMContentLoaded", () => { checkForFirstDeleteButton(); } - document.onsubmit = () => { - window.onbeforeunload = null; + // File upload functionality + let uploadedFileUUIDs: {uuid: string, filename: string}[] = []; + const fileUploadInput = document.getElementById("file-upload") as HTMLInputElement; + const uploadedFilesContainer = document.getElementById("uploaded-files")!; + const fileUploadZone = document.getElementById("file-upload-zone")!.querySelector('.border-dashed') as HTMLElement; + + // Handle file selection + const handleFiles = (files: FileList) => { + Array.from(files).forEach(file => { + if (!uploadedFileUUIDs.find(f => f.filename === file.name)) { + uploadFile(file); + } + }); }; + + // Upload file to server + const uploadFile = async (file: File) => { + const formData = new FormData(); + formData.append('file', file); + + // @ts-ignore + const baseUrl = window.opengist_base_url || ''; + const csrf = document.querySelector('form#create input[name="_csrf"]')?.value; + + try { + const response = await fetch(`${baseUrl}/upload`, { + method: 'POST', + credentials: 'same-origin', + body: formData, + headers: { + 'X-CSRF-Token': csrf || '' + } + }); + + if (response.ok) { + const result = await response.json(); + uploadedFileUUIDs.push({uuid: result.uuid, filename: result.filename}); + addFileToUI(result.filename, result.uuid, file.size); + } else { + console.error('Upload failed:', response.statusText); + } + } catch (error) { + console.error('Upload error:', error); + } + }; + + // Add file to UI + const addFileToUI = (filename: string, uuid: string, fileSize: number) => { + const fileElement = document.createElement('div'); + fileElement.className = 'flex items-stretch bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden'; + fileElement.dataset.uuid = uuid; + + fileElement.innerHTML = ` +
+ + + +
+

${filename}

+

${formatFileSize(fileSize)}

+
+
+ + `; + + // Remove file handler + fileElement.querySelector('.remove-file')!.addEventListener('click', async () => { + // Remove from server + try { + // @ts-ignore + const baseUrl = window.opengist_base_url || ''; + const csrf = document.querySelector('form#create input[name="_csrf"]')?.value; + + await fetch(`${baseUrl}/upload/${uuid}`, { + method: 'DELETE', + credentials: 'same-origin', + headers: { + 'X-CSRF-Token': csrf || '' + } + }); + } catch (error) { + console.error('Error deleting file:', error); + } + + // Remove from UI and local array + uploadedFileUUIDs = uploadedFileUUIDs.filter(f => f.uuid !== uuid); + fileElement.remove(); + }); + + uploadedFilesContainer.appendChild(fileElement); + }; + + // Format file size + const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + }; + + // File input change handler + fileUploadInput.addEventListener('change', (e) => { + const files = (e.target as HTMLInputElement).files; + if (files) { + handleFiles(files); + // Clear the input value immediately so it doesn't get submitted with the form + (e.target as HTMLInputElement).value = ''; + } + }); + + // Drag and drop handlers + fileUploadZone.addEventListener('dragover', (e) => { + e.preventDefault(); + fileUploadZone.classList.add('border-primary-400', 'dark:border-primary-500'); + }); + + fileUploadZone.addEventListener('dragleave', (e) => { + e.preventDefault(); + fileUploadZone.classList.remove('border-primary-400', 'dark:border-primary-500'); + }); + + fileUploadZone.addEventListener('drop', (e) => { + e.preventDefault(); + fileUploadZone.classList.remove('border-primary-400', 'dark:border-primary-500'); + + const files = e.dataTransfer?.files; + if (files) { + handleFiles(files); + } + }); + }); diff --git a/templates/pages/create.html b/templates/pages/create.html index 6fc016d..ded5228 100644 --- a/templates/pages/create.html +++ b/templates/pages/create.html @@ -41,6 +41,24 @@ {{ end }} +
+ +
+
+
@@ -65,6 +83,11 @@ {{ .csrfHtml }} + + +
diff --git a/templates/pages/edit.html b/templates/pages/edit.html index 89769dc..8b1f6c0 100644 --- a/templates/pages/edit.html +++ b/templates/pages/edit.html @@ -72,7 +72,23 @@ {{ template "_editor" . }} {{ end }} - +
+ +
+
{{ .locale.Tr "gist.edit.cancel" }} @@ -81,6 +97,11 @@ {{ .csrfHtml }} + + +
diff --git a/templates/partials/_editor.html b/templates/partials/_editor.html index 2201ab0..b4ce39b 100644 --- a/templates/partials/_editor.html +++ b/templates/partials/_editor.html @@ -1,5 +1,5 @@ {{ define "_editor" }} -
+

diff --git a/templates/partials/_gist_preview.html b/templates/partials/_gist_preview.html index fe83eeb..36e8dee 100644 --- a/templates/partials/_gist_preview.html +++ b/templates/partials/_gist_preview.html @@ -60,24 +60,28 @@

{{ if .gist.PreviewFilename }} - {{ if isMarkdown .gist.PreviewFilename }} -
{{ .gist.HTML | safe }}
- {{ else }} - - - {{ $ii := "1" }} - {{ $i := toInt $ii }} - {{ range $line := .gist.Lines }} + {{ if .gist.Preview }} + {{ if isMarkdown .gist.PreviewFilename }} +
{{ .gist.HTML | safe }}
+ {{ else }} +
+ + {{ $ii := "1" }} + {{ $i := toInt $ii }} + {{ range $line := .gist.Lines }} - - - - - {{ $i = inc $i }} - {{ end }} - -
{{$i}}{{ $line | safe }}
- {{ end }} + + {{$i}} + {{ $line | safe }} + + {{ $i = inc $i }} + {{ end }} + + + {{ end }} + {{ else }} +

{{ .locale.Tr "gist.preview-non-available" }}

+ {{ end }} {{ else }}

{{ .locale.Tr "gist.no-content" }}

{{ end }}