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
|
||||
*.sublime-project
|
||||
*.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.Put(prefix+"/orgs/:orgId/transactions/:transactionId", auth.RequireAuth(PutTransaction)),
|
||||
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.Post(prefix+"/orgs/:orgId/prices", auth.RequireAuth(PostPrice)),
|
||||
rest.Delete(prefix+"/orgs/:orgId/prices/:priceId", auth.RequireAuth(DeletePrice)),
|
||||
|
||||
@@ -993,3 +993,74 @@ func (_m *Datastore) VerifyUser(_a0 string) error {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
for _, account := range accounts {
|
||||
if account.Id == accountId {
|
||||
|
||||
@@ -162,6 +162,10 @@ func TestCreateAccount(t *testing.T) {
|
||||
|
||||
td := &TdAccount{}
|
||||
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{})
|
||||
|
||||
@@ -206,6 +210,10 @@ func TestUpdateAccount(t *testing.T) {
|
||||
|
||||
td := &TdAccount{}
|
||||
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{})
|
||||
|
||||
|
||||
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
|
||||
AccountInterface
|
||||
TransactionInterface
|
||||
AttachmentInterface
|
||||
PriceInterface
|
||||
SessionInterface
|
||||
ApiKeyInterface
|
||||
|
||||
@@ -247,6 +247,38 @@ func (m *GormModel) InsertTransaction(transaction *types.Transaction) error {
|
||||
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) {
|
||||
return m.repository.GetTransactionById(id)
|
||||
}
|
||||
|
||||
@@ -20,11 +20,13 @@ type Interface interface {
|
||||
OrgInterface
|
||||
AccountInterface
|
||||
TransactionInterface
|
||||
AttachmentInterface
|
||||
PriceInterface
|
||||
SessionInterface
|
||||
ApiKeyInterface
|
||||
SystemHealthInteface
|
||||
BudgetInterface
|
||||
GetTransaction(string, string, string) (*types.Transaction, error)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
if len(transaction.Splits) < 2 {
|
||||
return errors.New("at least 2 splits are required")
|
||||
|
||||
@@ -3,6 +3,7 @@ package util
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -44,3 +45,23 @@ func NewInviteId() (string, error) {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
log.Printf("recv: %s", message)
|
||||
log.Printf("recv: %+v", message)
|
||||
|
||||
// check version
|
||||
err = checkVersion(message.Version)
|
||||
|
||||
Reference in New Issue
Block a user