You've already forked openaccounting-server
forked from cybercinch/openaccounting-server
Compare commits
10 Commits
d10686e70f
...
e78098ad45
| Author | SHA1 | Date | |
|---|---|---|---|
| e78098ad45 | |||
| 7c43726abf | |||
| b7ac4b0152 | |||
| 1b115fe0ff | |||
| a87df47231 | |||
| 8b0a72c81f | |||
| f64f83e66f | |||
| f5f0853040 | |||
| 04653f2f02 | |||
| 3b89d8137e |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -97,3 +97,6 @@ config.json
|
|||||||
*.csr
|
*.csr
|
||||||
*.sublime-project
|
*.sublime-project
|
||||||
*.sublime-workspace
|
*.sublime-workspace
|
||||||
|
.vscode/
|
||||||
|
server
|
||||||
|
attachments/
|
||||||
|
|||||||
313
core/api/attachment.go
Normal file
313
core/api/attachment.go
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ant0ine/go-json-rest/rest"
|
||||||
|
"github.com/openaccounting/oa-server/core/model"
|
||||||
|
"github.com/openaccounting/oa-server/core/model/types"
|
||||||
|
"github.com/openaccounting/oa-server/core/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxFileSize = 10 * 1024 * 1024 // 10MB
|
||||||
|
MaxFilesPerTx = 10
|
||||||
|
AttachmentDir = "attachments"
|
||||||
|
)
|
||||||
|
|
||||||
|
var AllowedMimeTypes = map[string]bool{
|
||||||
|
"image/jpeg": true,
|
||||||
|
"image/png": true,
|
||||||
|
"image/gif": true,
|
||||||
|
"application/pdf": true,
|
||||||
|
"text/plain": true,
|
||||||
|
"text/csv": true,
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true, // .xlsx
|
||||||
|
"application/vnd.ms-excel": true, // .xls
|
||||||
|
}
|
||||||
|
|
||||||
|
func PostAttachment(w rest.ResponseWriter, r *rest.Request) {
|
||||||
|
orgId := r.PathParam("orgId")
|
||||||
|
transactionId := r.PathParam("transactionId")
|
||||||
|
|
||||||
|
if !util.IsValidUUID(orgId) || !util.IsValidUUID(transactionId) {
|
||||||
|
rest.Error(w, "Invalid UUID format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := r.Env["USER"].(*types.User)
|
||||||
|
|
||||||
|
// Parse multipart form
|
||||||
|
err := r.ParseMultipartForm(MaxFileSize)
|
||||||
|
if err != nil {
|
||||||
|
rest.Error(w, "Failed to parse multipart form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
files := r.MultipartForm.File["files"]
|
||||||
|
if len(files) == 0 {
|
||||||
|
rest.Error(w, "No files provided", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) > MaxFilesPerTx {
|
||||||
|
rest.Error(w, fmt.Sprintf("Too many files. Maximum %d files allowed", MaxFilesPerTx), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify transaction exists and user has permission
|
||||||
|
tx, err := model.Instance.GetTransaction(transactionId, orgId, user.Id)
|
||||||
|
if err != nil {
|
||||||
|
rest.Error(w, "Transaction not found or access denied", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tx == nil {
|
||||||
|
rest.Error(w, "Transaction not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var attachments []*types.Attachment
|
||||||
|
var description string
|
||||||
|
if desc := r.FormValue("description"); desc != "" {
|
||||||
|
description = desc
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, fileHeader := range files {
|
||||||
|
attachment, err := processFileUpload(fileHeader, transactionId, orgId, user.Id, description)
|
||||||
|
if err != nil {
|
||||||
|
// Clean up any successfully uploaded files
|
||||||
|
for _, att := range attachments {
|
||||||
|
os.Remove(att.FilePath)
|
||||||
|
}
|
||||||
|
rest.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save attachment to database
|
||||||
|
createdAttachment, err := model.Instance.CreateAttachment(attachment)
|
||||||
|
if err != nil {
|
||||||
|
// Clean up file and any previously uploaded files
|
||||||
|
os.Remove(attachment.FilePath)
|
||||||
|
for _, att := range attachments {
|
||||||
|
os.Remove(att.FilePath)
|
||||||
|
}
|
||||||
|
rest.Error(w, "Failed to save attachment", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
attachments = append(attachments, createdAttachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteJson(map[string]interface{}{
|
||||||
|
"attachments": attachments,
|
||||||
|
"count": len(attachments),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAttachments(w rest.ResponseWriter, r *rest.Request) {
|
||||||
|
orgId := r.PathParam("orgId")
|
||||||
|
transactionId := r.PathParam("transactionId")
|
||||||
|
|
||||||
|
if !util.IsValidUUID(orgId) || !util.IsValidUUID(transactionId) {
|
||||||
|
rest.Error(w, "Invalid UUID format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := r.Env["USER"].(*types.User)
|
||||||
|
|
||||||
|
attachments, err := model.Instance.GetAttachmentsByTransaction(transactionId, orgId, user.Id)
|
||||||
|
if err != nil {
|
||||||
|
rest.Error(w, "Failed to retrieve attachments", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteJson(attachments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAttachment(w rest.ResponseWriter, r *rest.Request) {
|
||||||
|
orgId := r.PathParam("orgId")
|
||||||
|
transactionId := r.PathParam("transactionId")
|
||||||
|
attachmentId := r.PathParam("attachmentId")
|
||||||
|
|
||||||
|
if !util.IsValidUUID(orgId) || !util.IsValidUUID(transactionId) || !util.IsValidUUID(attachmentId) {
|
||||||
|
rest.Error(w, "Invalid UUID format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := r.Env["USER"].(*types.User)
|
||||||
|
|
||||||
|
attachment, err := model.Instance.GetAttachment(attachmentId, transactionId, orgId, user.Id)
|
||||||
|
if err != nil {
|
||||||
|
rest.Error(w, "Attachment not found or access denied", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteJson(attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DownloadAttachment(w rest.ResponseWriter, r *rest.Request) {
|
||||||
|
orgId := r.PathParam("orgId")
|
||||||
|
transactionId := r.PathParam("transactionId")
|
||||||
|
attachmentId := r.PathParam("attachmentId")
|
||||||
|
|
||||||
|
if !util.IsValidUUID(orgId) || !util.IsValidUUID(transactionId) || !util.IsValidUUID(attachmentId) {
|
||||||
|
rest.Error(w, "Invalid UUID format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := r.Env["USER"].(*types.User)
|
||||||
|
|
||||||
|
attachment, err := model.Instance.GetAttachment(attachmentId, transactionId, orgId, user.Id)
|
||||||
|
if err != nil {
|
||||||
|
rest.Error(w, "Attachment not found or access denied", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if _, err := os.Stat(attachment.FilePath); os.IsNotExist(err) {
|
||||||
|
rest.Error(w, "File not found on disk", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers for file download
|
||||||
|
w.Header().Set("Content-Type", attachment.ContentType)
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", attachment.OriginalName))
|
||||||
|
|
||||||
|
// Open and serve file
|
||||||
|
file, err := os.Open(attachment.FilePath)
|
||||||
|
if err != nil {
|
||||||
|
rest.Error(w, "Failed to open file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
io.Copy(w.(http.ResponseWriter), file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteAttachment(w rest.ResponseWriter, r *rest.Request) {
|
||||||
|
orgId := r.PathParam("orgId")
|
||||||
|
transactionId := r.PathParam("transactionId")
|
||||||
|
attachmentId := r.PathParam("attachmentId")
|
||||||
|
|
||||||
|
if !util.IsValidUUID(orgId) || !util.IsValidUUID(transactionId) || !util.IsValidUUID(attachmentId) {
|
||||||
|
rest.Error(w, "Invalid UUID format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := r.Env["USER"].(*types.User)
|
||||||
|
|
||||||
|
err := model.Instance.DeleteAttachment(attachmentId, transactionId, orgId, user.Id)
|
||||||
|
if err != nil {
|
||||||
|
rest.Error(w, "Failed to delete attachment or access denied", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteJson(map[string]string{"status": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func processFileUpload(fileHeader *multipart.FileHeader, transactionId, orgId, userId, description string) (*types.Attachment, error) {
|
||||||
|
// Validate file size
|
||||||
|
if fileHeader.Size > MaxFileSize {
|
||||||
|
return nil, fmt.Errorf("file too large. Maximum size is %d bytes", MaxFileSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open uploaded file
|
||||||
|
file, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open uploaded file: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Validate file type from header
|
||||||
|
contentType := fileHeader.Header.Get("Content-Type")
|
||||||
|
if !AllowedMimeTypes[contentType] {
|
||||||
|
return nil, fmt.Errorf("file type %s not allowed", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file type by detecting content (more secure)
|
||||||
|
buffer := make([]byte, 512)
|
||||||
|
n, err := file.Read(buffer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read file for content detection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset file pointer to beginning
|
||||||
|
if _, err := file.Seek(0, 0); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to reset file pointer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
detectedType := http.DetectContentType(buffer[:n])
|
||||||
|
if !AllowedMimeTypes[detectedType] {
|
||||||
|
return nil, fmt.Errorf("detected file type %s not allowed (header claimed %s)", detectedType, contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
attachmentId := util.NewUUID()
|
||||||
|
ext := filepath.Ext(fileHeader.Filename)
|
||||||
|
fileName := attachmentId + ext
|
||||||
|
|
||||||
|
// Create attachments directory if it doesn't exist
|
||||||
|
uploadDir := filepath.Join(AttachmentDir, orgId, transactionId)
|
||||||
|
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create upload directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create file path
|
||||||
|
filePath := filepath.Join(uploadDir, fileName)
|
||||||
|
|
||||||
|
// Create destination file
|
||||||
|
dst, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create destination file: %v", err)
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
// Copy file contents
|
||||||
|
if _, err := io.Copy(dst, file); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to save file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create attachment object
|
||||||
|
attachment := &types.Attachment{
|
||||||
|
Id: attachmentId,
|
||||||
|
TransactionId: transactionId,
|
||||||
|
OrgId: orgId,
|
||||||
|
UserId: userId,
|
||||||
|
FileName: fileName,
|
||||||
|
OriginalName: fileHeader.Filename,
|
||||||
|
ContentType: contentType,
|
||||||
|
FileSize: fileHeader.Size,
|
||||||
|
FilePath: filePath,
|
||||||
|
Description: description,
|
||||||
|
Uploaded: time.Now(),
|
||||||
|
Deleted: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeFilename(filename string) string {
|
||||||
|
// Remove potentially dangerous characters
|
||||||
|
filename = strings.ReplaceAll(filename, "..", "")
|
||||||
|
filename = strings.ReplaceAll(filename, "/", "")
|
||||||
|
filename = strings.ReplaceAll(filename, "\\", "")
|
||||||
|
filename = strings.ReplaceAll(filename, "\x00", "") // null bytes
|
||||||
|
filename = strings.ReplaceAll(filename, "\r", "") // carriage return
|
||||||
|
filename = strings.ReplaceAll(filename, "\n", "") // newline
|
||||||
|
|
||||||
|
// Limit filename length
|
||||||
|
if len(filename) > 255 {
|
||||||
|
ext := filepath.Ext(filename)
|
||||||
|
base := filename[:255-len(ext)]
|
||||||
|
filename = base + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename
|
||||||
|
}
|
||||||
@@ -31,6 +31,11 @@ func GetRouter(auth *AuthMiddleware, prefix string) (rest.App, error) {
|
|||||||
rest.Post(prefix+"/orgs/:orgId/transactions", auth.RequireAuth(PostTransaction)),
|
rest.Post(prefix+"/orgs/:orgId/transactions", auth.RequireAuth(PostTransaction)),
|
||||||
rest.Put(prefix+"/orgs/:orgId/transactions/:transactionId", auth.RequireAuth(PutTransaction)),
|
rest.Put(prefix+"/orgs/:orgId/transactions/:transactionId", auth.RequireAuth(PutTransaction)),
|
||||||
rest.Delete(prefix+"/orgs/:orgId/transactions/:transactionId", auth.RequireAuth(DeleteTransaction)),
|
rest.Delete(prefix+"/orgs/:orgId/transactions/:transactionId", auth.RequireAuth(DeleteTransaction)),
|
||||||
|
rest.Get(prefix+"/orgs/:orgId/transactions/:transactionId/attachments", auth.RequireAuth(GetAttachments)),
|
||||||
|
rest.Post(prefix+"/orgs/:orgId/transactions/:transactionId/attachments", auth.RequireAuth(PostAttachment)),
|
||||||
|
rest.Get(prefix+"/orgs/:orgId/transactions/:transactionId/attachments/:attachmentId", auth.RequireAuth(GetAttachment)),
|
||||||
|
rest.Get(prefix+"/orgs/:orgId/transactions/:transactionId/attachments/:attachmentId/download", auth.RequireAuth(DownloadAttachment)),
|
||||||
|
rest.Delete(prefix+"/orgs/:orgId/transactions/:transactionId/attachments/:attachmentId", auth.RequireAuth(DeleteAttachment)),
|
||||||
rest.Get(prefix+"/orgs/:orgId/prices", auth.RequireAuth(GetPrices)),
|
rest.Get(prefix+"/orgs/:orgId/prices", auth.RequireAuth(GetPrices)),
|
||||||
rest.Post(prefix+"/orgs/:orgId/prices", auth.RequireAuth(PostPrice)),
|
rest.Post(prefix+"/orgs/:orgId/prices", auth.RequireAuth(PostPrice)),
|
||||||
rest.Delete(prefix+"/orgs/:orgId/prices/:priceId", auth.RequireAuth(DeletePrice)),
|
rest.Delete(prefix+"/orgs/:orgId/prices/:priceId", auth.RequireAuth(DeletePrice)),
|
||||||
|
|||||||
@@ -993,3 +993,74 @@ func (_m *Datastore) VerifyUser(_a0 string) error {
|
|||||||
|
|
||||||
return r0
|
return r0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attachment interface mock methods
|
||||||
|
func (_m *Datastore) InsertAttachment(_a0 *types.Attachment) error {
|
||||||
|
ret := _m.Called(_a0)
|
||||||
|
|
||||||
|
var r0 error
|
||||||
|
if rf, ok := ret.Get(0).(func(*types.Attachment) error); ok {
|
||||||
|
r0 = rf(_a0)
|
||||||
|
} else {
|
||||||
|
r0 = ret.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (_m *Datastore) GetAttachment(_a0 string, _a1 string, _a2 string) (*types.Attachment, error) {
|
||||||
|
ret := _m.Called(_a0, _a1, _a2)
|
||||||
|
|
||||||
|
var r0 *types.Attachment
|
||||||
|
if rf, ok := ret.Get(0).(func(string, string, string) *types.Attachment); ok {
|
||||||
|
r0 = rf(_a0, _a1, _a2)
|
||||||
|
} else {
|
||||||
|
if ret.Get(0) != nil {
|
||||||
|
r0 = ret.Get(0).(*types.Attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var r1 error
|
||||||
|
if rf, ok := ret.Get(1).(func(string, string, string) error); ok {
|
||||||
|
r1 = rf(_a0, _a1, _a2)
|
||||||
|
} else {
|
||||||
|
r1 = ret.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r0, r1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (_m *Datastore) GetAttachmentsByTransaction(_a0 string, _a1 string) ([]*types.Attachment, error) {
|
||||||
|
ret := _m.Called(_a0, _a1)
|
||||||
|
|
||||||
|
var r0 []*types.Attachment
|
||||||
|
if rf, ok := ret.Get(0).(func(string, string) []*types.Attachment); ok {
|
||||||
|
r0 = rf(_a0, _a1)
|
||||||
|
} else {
|
||||||
|
if ret.Get(0) != nil {
|
||||||
|
r0 = ret.Get(0).([]*types.Attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var r1 error
|
||||||
|
if rf, ok := ret.Get(1).(func(string, string) error); ok {
|
||||||
|
r1 = rf(_a0, _a1)
|
||||||
|
} else {
|
||||||
|
r1 = ret.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r0, r1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (_m *Datastore) DeleteAttachment(_a0 string, _a1 string, _a2 string) error {
|
||||||
|
ret := _m.Called(_a0, _a1, _a2)
|
||||||
|
|
||||||
|
var r0 error
|
||||||
|
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
|
||||||
|
r0 = rf(_a0, _a1, _a2)
|
||||||
|
} else {
|
||||||
|
r0 = ret.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r0
|
||||||
|
}
|
||||||
|
|||||||
@@ -408,6 +408,15 @@ func (model *Model) accountsContainWriteAccess(accounts []*types.Account, accoun
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (model *Model) accountsContainReadAccess(accounts []*types.Account, accountId string) bool {
|
||||||
|
for _, account := range accounts {
|
||||||
|
if account.Id == accountId {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (model *Model) getAccountFromList(accounts []*types.Account, accountId string) *types.Account {
|
func (model *Model) getAccountFromList(accounts []*types.Account, accountId string) *types.Account {
|
||||||
for _, account := range accounts {
|
for _, account := range accounts {
|
||||||
if account.Id == accountId {
|
if account.Id == accountId {
|
||||||
|
|||||||
@@ -162,6 +162,10 @@ func TestCreateAccount(t *testing.T) {
|
|||||||
|
|
||||||
td := &TdAccount{}
|
td := &TdAccount{}
|
||||||
td.On("GetAccountsByOrgId", "1").Return(getTestAccounts(), nil)
|
td.On("GetAccountsByOrgId", "1").Return(getTestAccounts(), nil)
|
||||||
|
// Mock GetSplitCountByAccountId for parent account check
|
||||||
|
if test.account.Parent != "" {
|
||||||
|
td.On("GetSplitCountByAccountId", test.account.Parent).Return(int64(0), nil)
|
||||||
|
}
|
||||||
|
|
||||||
model := NewModel(td, nil, types.Config{})
|
model := NewModel(td, nil, types.Config{})
|
||||||
|
|
||||||
@@ -206,6 +210,10 @@ func TestUpdateAccount(t *testing.T) {
|
|||||||
|
|
||||||
td := &TdAccount{}
|
td := &TdAccount{}
|
||||||
td.On("GetAccountsByOrgId", "1").Return(getTestAccounts(), nil)
|
td.On("GetAccountsByOrgId", "1").Return(getTestAccounts(), nil)
|
||||||
|
// Mock GetSplitCountByAccountId for parent account check
|
||||||
|
if test.account.Parent != "" {
|
||||||
|
td.On("GetSplitCountByAccountId", test.account.Parent).Return(int64(0), nil)
|
||||||
|
}
|
||||||
|
|
||||||
model := NewModel(td, nil, types.Config{})
|
model := NewModel(td, nil, types.Config{})
|
||||||
|
|
||||||
|
|||||||
163
core/model/attachment.go
Normal file
163
core/model/attachment.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/openaccounting/oa-server/core/model/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AttachmentInterface interface {
|
||||||
|
CreateAttachment(*types.Attachment) (*types.Attachment, error)
|
||||||
|
GetAttachmentsByTransaction(string, string, string) ([]*types.Attachment, error)
|
||||||
|
GetAttachment(string, string, string, string) (*types.Attachment, error)
|
||||||
|
DeleteAttachment(string, string, string, string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (model *Model) CreateAttachment(attachment *types.Attachment) (*types.Attachment, error) {
|
||||||
|
if attachment.Id == "" {
|
||||||
|
return nil, errors.New("attachment ID required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if attachment.TransactionId == "" {
|
||||||
|
return nil, errors.New("transaction ID required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if attachment.OrgId == "" {
|
||||||
|
return nil, errors.New("organization ID required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if attachment.UserId == "" {
|
||||||
|
return nil, errors.New("user ID required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if attachment.FileName == "" {
|
||||||
|
return nil, errors.New("file name required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if attachment.FilePath == "" {
|
||||||
|
return nil, errors.New("file path required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set upload timestamp
|
||||||
|
attachment.Uploaded = time.Now()
|
||||||
|
attachment.Deleted = false
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
err := model.db.InsertAttachment(attachment)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (model *Model) GetAttachmentsByTransaction(transactionId, orgId, userId string) ([]*types.Attachment, error) {
|
||||||
|
if transactionId == "" {
|
||||||
|
return nil, errors.New("transaction ID required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if orgId == "" {
|
||||||
|
return nil, errors.New("organization ID required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if userId == "" {
|
||||||
|
return nil, errors.New("user ID required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// First verify the user has access to the transaction
|
||||||
|
tx, err := model.GetTransaction(transactionId, orgId, userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tx == nil {
|
||||||
|
return nil, errors.New("transaction not found or access denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get attachments for the transaction
|
||||||
|
attachments, err := model.db.GetAttachmentsByTransaction(transactionId, orgId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (model *Model) GetAttachment(attachmentId, transactionId, orgId, userId string) (*types.Attachment, error) {
|
||||||
|
if attachmentId == "" {
|
||||||
|
return nil, errors.New("attachment ID required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if transactionId == "" {
|
||||||
|
return nil, errors.New("transaction ID required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if orgId == "" {
|
||||||
|
return nil, errors.New("organization ID required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if userId == "" {
|
||||||
|
return nil, errors.New("user ID required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// First verify the user has access to the transaction
|
||||||
|
tx, err := model.GetTransaction(transactionId, orgId, userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tx == nil {
|
||||||
|
return nil, errors.New("transaction not found or access denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the attachment
|
||||||
|
attachment, err := model.db.GetAttachment(attachmentId, transactionId, orgId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (model *Model) DeleteAttachment(attachmentId, transactionId, orgId, userId string) error {
|
||||||
|
if attachmentId == "" {
|
||||||
|
return errors.New("attachment ID required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if transactionId == "" {
|
||||||
|
return errors.New("transaction ID required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if orgId == "" {
|
||||||
|
return errors.New("organization ID required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if userId == "" {
|
||||||
|
return errors.New("user ID required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// First verify the user has access to the transaction
|
||||||
|
tx, err := model.GetTransaction(transactionId, orgId, userId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tx == nil {
|
||||||
|
return errors.New("transaction not found or access denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the attachment exists and belongs to the transaction
|
||||||
|
attachment, err := model.db.GetAttachment(attachmentId, transactionId, orgId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if attachment == nil {
|
||||||
|
return errors.New("attachment not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete the attachment
|
||||||
|
err = model.db.DeleteAttachment(attachmentId, transactionId, orgId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
126
core/model/db/attachment.go
Normal file
126
core/model/db/attachment.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/openaccounting/oa-server/core/model/types"
|
||||||
|
"github.com/openaccounting/oa-server/core/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
const attachmentFields = "LOWER(HEX(id)),LOWER(HEX(transactionId)),LOWER(HEX(orgId)),LOWER(HEX(userId)),fileName,originalName,contentType,fileSize,filePath,description,uploaded,deleted"
|
||||||
|
|
||||||
|
type AttachmentInterface interface {
|
||||||
|
InsertAttachment(*types.Attachment) error
|
||||||
|
GetAttachment(string, string, string) (*types.Attachment, error)
|
||||||
|
GetAttachmentsByTransaction(string, string) ([]*types.Attachment, error)
|
||||||
|
DeleteAttachment(string, string, string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) InsertAttachment(attachment *types.Attachment) error {
|
||||||
|
query := "INSERT INTO attachment(id,transactionId,orgId,userId,fileName,originalName,contentType,fileSize,filePath,description,uploaded,deleted) VALUES(UNHEX(?),UNHEX(?),UNHEX(?),UNHEX(?),?,?,?,?,?,?,?,?)"
|
||||||
|
|
||||||
|
_, err := db.Exec(
|
||||||
|
query,
|
||||||
|
attachment.Id,
|
||||||
|
attachment.TransactionId,
|
||||||
|
attachment.OrgId,
|
||||||
|
attachment.UserId,
|
||||||
|
attachment.FileName,
|
||||||
|
attachment.OriginalName,
|
||||||
|
attachment.ContentType,
|
||||||
|
attachment.FileSize,
|
||||||
|
attachment.FilePath,
|
||||||
|
attachment.Description,
|
||||||
|
util.TimeToMs(attachment.Uploaded),
|
||||||
|
attachment.Deleted,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetAttachment(attachmentId, transactionId, orgId string) (*types.Attachment, error) {
|
||||||
|
query := "SELECT " + attachmentFields + " FROM attachment WHERE id = UNHEX(?) AND transactionId = UNHEX(?) AND orgId = UNHEX(?) AND deleted = false"
|
||||||
|
row := db.QueryRow(query, attachmentId, transactionId, orgId)
|
||||||
|
|
||||||
|
return db.unmarshalAttachment(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetAttachmentsByTransaction(transactionId, orgId string) ([]*types.Attachment, error) {
|
||||||
|
query := "SELECT " + attachmentFields + " FROM attachment WHERE transactionId = UNHEX(?) AND orgId = UNHEX(?) AND deleted = false ORDER BY uploaded DESC"
|
||||||
|
rows, err := db.Query(query, transactionId, orgId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.unmarshalAttachments(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) DeleteAttachment(attachmentId, transactionId, orgId string) error {
|
||||||
|
query := "UPDATE attachment SET deleted = true WHERE id = UNHEX(?) AND transactionId = UNHEX(?) AND orgId = UNHEX(?)"
|
||||||
|
_, err := db.Exec(query, attachmentId, transactionId, orgId)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) unmarshalAttachment(row *sql.Row) (*types.Attachment, error) {
|
||||||
|
attachment := &types.Attachment{}
|
||||||
|
var uploaded int64
|
||||||
|
|
||||||
|
err := row.Scan(
|
||||||
|
&attachment.Id,
|
||||||
|
&attachment.TransactionId,
|
||||||
|
&attachment.OrgId,
|
||||||
|
&attachment.UserId,
|
||||||
|
&attachment.FileName,
|
||||||
|
&attachment.OriginalName,
|
||||||
|
&attachment.ContentType,
|
||||||
|
&attachment.FileSize,
|
||||||
|
&attachment.FilePath,
|
||||||
|
&attachment.Description,
|
||||||
|
&uploaded,
|
||||||
|
&attachment.Deleted,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
attachment.Uploaded = util.MsToTime(uploaded)
|
||||||
|
|
||||||
|
return attachment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) unmarshalAttachments(rows *sql.Rows) ([]*types.Attachment, error) {
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
attachments := []*types.Attachment{}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
attachment := &types.Attachment{}
|
||||||
|
var uploaded int64
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&attachment.Id,
|
||||||
|
&attachment.TransactionId,
|
||||||
|
&attachment.OrgId,
|
||||||
|
&attachment.UserId,
|
||||||
|
&attachment.FileName,
|
||||||
|
&attachment.OriginalName,
|
||||||
|
&attachment.ContentType,
|
||||||
|
&attachment.FileSize,
|
||||||
|
&attachment.FilePath,
|
||||||
|
&attachment.Description,
|
||||||
|
&uploaded,
|
||||||
|
&attachment.Deleted,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
attachment.Uploaded = util.MsToTime(uploaded)
|
||||||
|
attachments = append(attachments, attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachments, nil
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ type Datastore interface {
|
|||||||
OrgInterface
|
OrgInterface
|
||||||
AccountInterface
|
AccountInterface
|
||||||
TransactionInterface
|
TransactionInterface
|
||||||
|
AttachmentInterface
|
||||||
PriceInterface
|
PriceInterface
|
||||||
SessionInterface
|
SessionInterface
|
||||||
ApiKeyInterface
|
ApiKeyInterface
|
||||||
|
|||||||
@@ -247,6 +247,38 @@ func (m *GormModel) InsertTransaction(transaction *types.Transaction) error {
|
|||||||
return m.repository.InsertTransaction(transaction)
|
return m.repository.InsertTransaction(transaction)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *GormModel) GetTransaction(transactionId, orgId, userId string) (*types.Transaction, error) {
|
||||||
|
// For now, delegate to repository - in a full implementation, this would include permission checking
|
||||||
|
return m.repository.GetTransactionById(transactionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachmentInterface implementation
|
||||||
|
func (m *GormModel) CreateAttachment(attachment *types.Attachment) (*types.Attachment, error) {
|
||||||
|
if attachment.Id == "" {
|
||||||
|
return nil, errors.New("attachment ID required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set upload timestamp
|
||||||
|
attachment.Uploaded = time.Now()
|
||||||
|
attachment.Deleted = false
|
||||||
|
|
||||||
|
// For GORM implementation, we'd need to implement repository methods
|
||||||
|
// For now, return an error indicating not implemented
|
||||||
|
return nil, errors.New("attachment operations not yet implemented for GORM model")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GormModel) GetAttachmentsByTransaction(transactionId, orgId, userId string) ([]*types.Attachment, error) {
|
||||||
|
return nil, errors.New("attachment operations not yet implemented for GORM model")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GormModel) GetAttachment(attachmentId, transactionId, orgId, userId string) (*types.Attachment, error) {
|
||||||
|
return nil, errors.New("attachment operations not yet implemented for GORM model")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GormModel) DeleteAttachment(attachmentId, transactionId, orgId, userId string) error {
|
||||||
|
return errors.New("attachment operations not yet implemented for GORM model")
|
||||||
|
}
|
||||||
|
|
||||||
func (m *GormModel) GetTransactionById(id string) (*types.Transaction, error) {
|
func (m *GormModel) GetTransactionById(id string) (*types.Transaction, error) {
|
||||||
return m.repository.GetTransactionById(id)
|
return m.repository.GetTransactionById(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ type Interface interface {
|
|||||||
OrgInterface
|
OrgInterface
|
||||||
AccountInterface
|
AccountInterface
|
||||||
TransactionInterface
|
TransactionInterface
|
||||||
|
AttachmentInterface
|
||||||
PriceInterface
|
PriceInterface
|
||||||
SessionInterface
|
SessionInterface
|
||||||
ApiKeyInterface
|
ApiKeyInterface
|
||||||
SystemHealthInteface
|
SystemHealthInteface
|
||||||
BudgetInterface
|
BudgetInterface
|
||||||
|
GetTransaction(string, string, string) (*types.Transaction, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel(db db.Datastore, bcrypt util.Bcrypt, config types.Config) *Model {
|
func NewModel(db db.Datastore, bcrypt util.Bcrypt, config types.Config) *Model {
|
||||||
|
|||||||
@@ -169,6 +169,31 @@ func (model *Model) getTransactionById(id string) (*types.Transaction, error) {
|
|||||||
return model.db.GetTransactionById(id)
|
return model.db.GetTransactionById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (model *Model) GetTransaction(transactionId, orgId, userId string) (*types.Transaction, error) {
|
||||||
|
transaction, err := model.getTransactionById(transactionId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if transaction == nil || transaction.OrgId != orgId {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to all accounts in the transaction
|
||||||
|
userAccounts, err := model.GetAccounts(orgId, userId, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, split := range transaction.Splits {
|
||||||
|
if !model.accountsContainReadAccess(userAccounts, split.AccountId) {
|
||||||
|
return nil, fmt.Errorf("user does not have permission to access account %s", split.AccountId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return transaction, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (model *Model) checkSplits(transaction *types.Transaction) (err error) {
|
func (model *Model) checkSplits(transaction *types.Transaction) (err error) {
|
||||||
if len(transaction.Splits) < 2 {
|
if len(transaction.Splits) < 2 {
|
||||||
return errors.New("at least 2 splits are required")
|
return errors.New("at least 2 splits are required")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package util
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"regexp"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,3 +45,23 @@ func NewInviteId() (string, error) {
|
|||||||
|
|
||||||
return hex.EncodeToString(byteArray), nil
|
return hex.EncodeToString(byteArray), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewUUID() string {
|
||||||
|
guid, err := NewGuid()
|
||||||
|
if err != nil {
|
||||||
|
// Fallback to timestamp-based UUID if random generation fails
|
||||||
|
return hex.EncodeToString([]byte(time.Now().Format("20060102150405")))
|
||||||
|
}
|
||||||
|
return guid
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsValidUUID(uuid string) bool {
|
||||||
|
// Check if the string is a valid 32-character hex string (16 bytes * 2 hex chars)
|
||||||
|
if len(uuid) != 32 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all characters are valid hex characters
|
||||||
|
matched, _ := regexp.MatchString("^[0-9a-f]{32}$", uuid)
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func Handler(w rest.ResponseWriter, r *rest.Request) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("recv: %s", message)
|
log.Printf("recv: %+v", message)
|
||||||
|
|
||||||
// check version
|
// check version
|
||||||
err = checkVersion(message.Version)
|
err = checkVersion(message.Version)
|
||||||
|
|||||||
Reference in New Issue
Block a user