You've already forked openaccounting-server
forked from cybercinch/openaccounting-server
Compare commits
12 Commits
v0.1.0
...
e78098ad45
| Author | SHA1 | Date | |
|---|---|---|---|
| e78098ad45 | |||
| 7c43726abf | |||
| b7ac4b0152 | |||
| 1b115fe0ff | |||
| a87df47231 | |||
| 8b0a72c81f | |||
| f64f83e66f | |||
| f5f0853040 | |||
| 04653f2f02 | |||
| 3b89d8137e | |||
| d10686e70f | |||
| c335c834ba |
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")
|
||||
|
||||
20
core/model/types/attachment.go
Normal file
20
core/model/types/attachment.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Attachment struct {
|
||||
Id string `json:"id"`
|
||||
TransactionId string `json:"transactionId"`
|
||||
OrgId string `json:"orgId"`
|
||||
UserId string `json:"userId"`
|
||||
FileName string `json:"fileName"`
|
||||
OriginalName string `json:"originalName"`
|
||||
ContentType string `json:"contentType"`
|
||||
FileSize int64 `json:"fileSize"`
|
||||
FilePath string `json:"filePath"`
|
||||
Description string `json:"description"`
|
||||
Uploaded time.Time `json:"uploaded"`
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -3,4 +3,8 @@ CREATE INDEX split_accountId_index ON split (accountId);
|
||||
CREATE INDEX split_transactionId_index ON split (transactionId);
|
||||
CREATE INDEX split_date_index ON split (date);
|
||||
CREATE INDEX split_updated_index ON split (updated);
|
||||
CREATE INDEX budgetitem_orgId_index ON budgetitem (orgId);
|
||||
CREATE INDEX budgetitem_orgId_index ON budgetitem (orgId);
|
||||
CREATE INDEX attachment_transactionId_index ON attachment (transactionId);
|
||||
CREATE INDEX attachment_orgId_index ON attachment (orgId);
|
||||
CREATE INDEX attachment_userId_index ON attachment (userId);
|
||||
CREATE INDEX attachment_uploaded_index ON attachment (uploaded);
|
||||
28
models/attachment.go
Normal file
28
models/attachment.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Attachment struct {
|
||||
ID []byte `gorm:"type:BINARY(16);primaryKey"`
|
||||
TransactionID []byte `gorm:"column:transactionId;type:BINARY(16);not null"`
|
||||
OrgID []byte `gorm:"column:orgId;type:BINARY(16);not null"`
|
||||
UserID []byte `gorm:"column:userId;type:BINARY(16);not null"`
|
||||
FileName string `gorm:"column:fileName;size:255;not null"`
|
||||
OriginalName string `gorm:"column:originalName;size:255;not null"`
|
||||
ContentType string `gorm:"column:contentType;size:100;not null"`
|
||||
FileSize int64 `gorm:"column:fileSize;not null"`
|
||||
FilePath string `gorm:"column:filePath;size:500;not null"`
|
||||
Description string `gorm:"column:description;size:500"`
|
||||
Uploaded time.Time `gorm:"column:uploaded;not null"`
|
||||
Deleted bool `gorm:"column:deleted;default:false"`
|
||||
|
||||
Transaction Transaction `gorm:"foreignKey:TransactionID"`
|
||||
Org Org `gorm:"foreignKey:OrgID"`
|
||||
User User `gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
func (Attachment) TableName() string {
|
||||
return "attachment"
|
||||
}
|
||||
@@ -30,4 +30,6 @@ CREATE TABLE apikey (id BINARY(16) NOT NULL, inserted BIGINT UNSIGNED NOT NULL,
|
||||
|
||||
CREATE TABLE invite (id VARCHAR(32) NOT NULL, orgId BINARY(16) NOT NULL, inserted BIGINT UNSIGNED NOT NULL, updated BIGINT UNSIGNED NOT NULL, email VARCHAR(100) NOT NULL, accepted BOOLEAN NOT NULL, PRIMARY KEY(id)) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE budgetitem (id INT UNSIGNED NOT NULL AUTO_INCREMENT, orgId BINARY(16) NOT NULL, accountId BINARY(16) NOT NULL, inserted BIGINT UNSIGNED NOT NULL, amount BIGINT NOT NULL, PRIMARY KEY(id)) ENGINE=InnoDB;
|
||||
CREATE TABLE budgetitem (id INT UNSIGNED NOT NULL AUTO_INCREMENT, orgId BINARY(16) NOT NULL, accountId BINARY(16) NOT NULL, inserted BIGINT UNSIGNED NOT NULL, amount BIGINT NOT NULL, PRIMARY KEY(id)) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE attachment (id BINARY(16) NOT NULL, transactionId BINARY(16) NOT NULL, orgId BINARY(16) NOT NULL, userId BINARY(16) NOT NULL, fileName VARCHAR(255) NOT NULL, originalName VARCHAR(255) NOT NULL, contentType VARCHAR(100) NOT NULL, fileSize BIGINT NOT NULL, filePath VARCHAR(500) NOT NULL, description VARCHAR(500), uploaded BIGINT UNSIGNED NOT NULL, deleted BOOLEAN NOT NULL DEFAULT false, PRIMARY KEY(id)) ENGINE=InnoDB;
|
||||
Reference in New Issue
Block a user