diff --git a/.gitignore b/.gitignore index e9ad3939..0eaa0c59 100755 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ backups/* # Go binaries go/server go/vault +go/go.mod +go/go.sum +go/goose.env diff --git a/Dockerfile.local.php b/Dockerfile.local.php new file mode 100644 index 00000000..52dc686d --- /dev/null +++ b/Dockerfile.local.php @@ -0,0 +1,47 @@ +# Use the official PHP 5.6 Apache image for classic mod_php +FROM php:5.6-apache + +# Install required system libraries and PHP extensions for CakePHP +RUN sed -i 's|http://deb.debian.org/debian|http://archive.debian.org/debian|g' /etc/apt/sources.list && \ + sed -i 's|http://security.debian.org/debian-security|http://archive.debian.org/debian-security|g' /etc/apt/sources.list && \ + sed -i '/stretch-updates/d' /etc/apt/sources.list && \ + echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99allow-insecure && \ + echo 'Acquire::AllowDowngradeToInsecureRepositories "true";' >> /etc/apt/apt.conf.d/99allow-insecure && \ + apt-get update && \ + apt-get install --allow-unauthenticated -y libc-client2007e-dev libkrb5-dev libpng-dev libjpeg-dev libfreetype6-dev libcurl4-openssl-dev libxml2-dev libssl-dev libmcrypt-dev libicu-dev && \ + docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ && \ + docker-php-ext-configure imap --with-kerberos --with-imap-ssl && \ + docker-php-ext-install mysqli pdo pdo_mysql mbstring gd curl imap + +# Set environment variables. +ENV HOME /root + +# Define working directory. +WORKDIR /root + +ARG COMMIT +ENV COMMIT_SHA=${COMMIT} + +EXPOSE 80 + +# Copy vhost config to Apache's sites-available +ADD conf/apache-vhost.conf /etc/apache2/sites-available/cmc-sales.conf +ADD conf/ripmime /bin/ripmime + +RUN chmod +x /bin/ripmime \ + && a2ensite cmc-sales \ + && a2dissite 000-default \ + && a2enmod rewrite \ + && a2enmod headers + +# Copy site into place. +ADD php/ /var/www/cmc-sales +ADD php/app/config/database_local.php /var/www/cmc-sales/app/config/database.php +RUN mkdir -p /var/www/cmc-sales/app/tmp +RUN mkdir -p /var/www/cmc-sales/app/tmp/logs +RUN chmod -R 755 /var/www/cmc-sales/app/tmp + +# Ensure CakePHP tmp directory is writable by web server +RUN chmod -R 777 /var/www/cmc-sales/app/tmp +# By default, simply start apache. +CMD /usr/sbin/apache2ctl -D FOREGROUND diff --git a/conf/nginx-site.conf b/conf/nginx-site.conf index 0940883b..2fc1e93f 100644 --- a/conf/nginx-site.conf +++ b/conf/nginx-site.conf @@ -1,9 +1,14 @@ +resolver 127.0.0.11 valid=10s; + server { server_name cmclocal; auth_basic_user_file /etc/nginx/userpasswd; auth_basic "Restricted"; + client_max_body_size 200M; + location /go/ { - proxy_pass http://cmc-go:8080; + set $upstream_go cmc-go:8080; + proxy_pass http://$upstream_go; proxy_read_timeout 300s; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -11,7 +16,8 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } location / { - proxy_pass http://cmc-php:80; + set $upstream_php cmc-php:80; + proxy_pass http://$upstream_php; proxy_read_timeout 300s; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/conf/nginx-site.prod.conf b/conf/nginx-site.prod.conf index 8c1a129e..15a6ad1b 100644 --- a/conf/nginx-site.prod.conf +++ b/conf/nginx-site.prod.conf @@ -2,6 +2,7 @@ server { server_name cmclocal; auth_basic_user_file /etc/nginx/userpasswd; auth_basic "Restricted"; + client_max_body_size 200M; location /go/ { proxy_pass http://cmc-prod-go:8082; proxy_read_timeout 300s; diff --git a/conf/nginx-site.stg.conf b/conf/nginx-site.stg.conf index 52f0d990..99f6d30f 100644 --- a/conf/nginx-site.stg.conf +++ b/conf/nginx-site.stg.conf @@ -2,6 +2,7 @@ server { server_name cmclocal; auth_basic_user_file /etc/nginx/userpasswd; auth_basic "Restricted"; + client_max_body_size 200M; location /go/ { proxy_pass http://cmc-stg-go:8082; proxy_read_timeout 300s; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6c264f35..64e325a1 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -47,6 +47,7 @@ services: DB_USER: cmc DB_PASSWORD: xVRQI&cA?7AU=hqJ!%au DB_NAME: cmc + GO_APP_HOST: cmc-prod-go:8082 volumes: - /home/cmc/files/pdf:/var/www/cmc-sales/app/webroot/pdf - /home/cmc/files/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files diff --git a/docker-compose.stg.yml b/docker-compose.stg.yml index f0cf1437..d8f9c98a 100644 --- a/docker-compose.stg.yml +++ b/docker-compose.stg.yml @@ -45,6 +45,7 @@ services: DB_USER: cmc DB_PASSWORD: xVRQI&cA?7AU=hqJ!%au DB_NAME: cmc + GO_APP_HOST: cmc-stg-go:8082 volumes: - /home/cmc/files/pdf:/var/www/cmc-sales/app/webroot/pdf - /home/cmc/files/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files diff --git a/docker-compose.yml b/docker-compose.yml index 2d206db7..e34db788 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,10 @@ services: - ./conf/nginx-site.conf:/etc/nginx/conf.d/cmc.conf - ./userpasswd:/etc/nginx/userpasswd:ro depends_on: - - cmc-php + cmc-php: + condition: service_started + cmc-go: + condition: service_started restart: unless-stopped networks: - cmc-network @@ -16,9 +19,11 @@ services: cmc-php: build: context: . - dockerfile: Dockerfile.stg.php + dockerfile: Dockerfile.local.php platform: linux/amd64 container_name: cmc-php + environment: + GO_APP_HOST: cmc-go:8080 depends_on: - db volumes: diff --git a/go/cmd/server/main.go b/go/cmd/server/main.go index f2238a62..d081aa51 100644 --- a/go/cmd/server/main.go +++ b/go/cmd/server/main.go @@ -10,6 +10,7 @@ import ( "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db" "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/email" + "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/attachments" quotes "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/quotes" "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates" "github.com/go-co-op/gocron" @@ -60,6 +61,7 @@ func main() { // Load handlers quoteHandler := quotes.NewQuotesHandler(queries, tmpl, emailService) + attachmentHandler := attachments.NewAttachmentHandler(queries) // Setup routes r := mux.NewRouter() @@ -77,6 +79,11 @@ func main() { goRouter.HandleFunc("/quotes/disable-reminders", quoteHandler.DisableReminders).Methods("POST") goRouter.HandleFunc("/quotes/enable-reminders", quoteHandler.EnableReminders).Methods("POST") + // Attachment routes + goRouter.HandleFunc("/attachments/upload", attachmentHandler.Create).Methods("POST") + goRouter.HandleFunc("/attachments/{id}", attachmentHandler.Get).Methods("GET") + goRouter.HandleFunc("/attachments/{id}", attachmentHandler.Delete).Methods("DELETE") + // The following routes are currently disabled: /* // API routes diff --git a/go/internal/cmc/handlers/attachments.go b/go/internal/cmc/handlers/attachments/attachments.go similarity index 84% rename from go/internal/cmc/handlers/attachments.go rename to go/internal/cmc/handlers/attachments/attachments.go index 94599939..09c6eec7 100644 --- a/go/internal/cmc/handlers/attachments.go +++ b/go/internal/cmc/handlers/attachments/attachments.go @@ -1,4 +1,4 @@ -package handlers +package attachments import ( "database/sql" @@ -111,18 +111,22 @@ func (h *AttachmentHandler) Create(w http.ResponseWriter, r *http.Request) { return } - // Get file from form - file, handler, err := r.FormFile("file") + // Get file from form - try both field names (CakePHP format and plain) + file, handler, err := r.FormFile("data[Attachment][file]") if err != nil { - http.Error(w, "No file uploaded", http.StatusBadRequest) - return + // Try plain "file" field name as fallback + file, handler, err = r.FormFile("file") + if err != nil { + http.Error(w, "No file uploaded", http.StatusBadRequest) + return + } } defer file.Close() // Generate unique filename ext := filepath.Ext(handler.Filename) filename := fmt.Sprintf("%d_%s%s", time.Now().Unix(), handler.Filename[:len(handler.Filename)-len(ext)], ext) - + // Create attachments directory if it doesn't exist attachDir := "webroot/attachments_files" if err := os.MkdirAll(attachDir, 0755); err != nil { @@ -131,8 +135,8 @@ func (h *AttachmentHandler) Create(w http.ResponseWriter, r *http.Request) { } // Save file to disk - filepath := filepath.Join(attachDir, filename) - dst, err := os.Create(filepath) + filePath := filepath.Join(attachDir, filename) + dst, err := os.Create(filePath) if err != nil { http.Error(w, "Failed to save file", http.StatusInternalServerError) return @@ -144,23 +148,38 @@ func (h *AttachmentHandler) Create(w http.ResponseWriter, r *http.Request) { return } - // Parse principle_id + // Parse principle_id - try CakePHP format first, then fallback principleID := 1 // Default - if pid := r.FormValue("principle_id"); pid != "" { + pid := r.FormValue("data[Attachment][principle_id]") + if pid == "" { + pid = r.FormValue("principle_id") + } + if pid != "" { if id, err := strconv.Atoi(pid); err == nil { principleID = id } } + // Get other form values - try CakePHP format first, then fallback + name := r.FormValue("data[Attachment][name]") + if name == "" { + name = r.FormValue("name") + } + + description := r.FormValue("data[Attachment][description]") + if description == "" { + description = r.FormValue("description") + } + // Create database record params := db.CreateAttachmentParams{ PrincipleID: int32(principleID), - Name: r.FormValue("name"), + Name: name, Filename: handler.Filename, - File: filename, + File: filePath, // Store full path for PHP compatibility Type: handler.Header.Get("Content-Type"), Size: int32(handler.Size), - Description: r.FormValue("description"), + Description: description, } if params.Name == "" { @@ -170,7 +189,7 @@ func (h *AttachmentHandler) Create(w http.ResponseWriter, r *http.Request) { result, err := h.queries.CreateAttachment(r.Context(), params) if err != nil { // Clean up file on error - os.Remove(filepath) + os.Remove(filePath) http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -216,13 +235,13 @@ func (h *AttachmentHandler) Update(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - + params = db.UpdateAttachmentParams{ Name: r.FormValue("name"), Description: r.FormValue("description"), } } - + params.ID = int32(id) if err := h.queries.UpdateAttachment(r.Context(), params); err != nil { @@ -248,4 +267,4 @@ func (h *AttachmentHandler) Delete(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusNoContent) -} \ No newline at end of file +} diff --git a/go/internal/cmc/handlers/attachments/attachments_test.go b/go/internal/cmc/handlers/attachments/attachments_test.go new file mode 100644 index 00000000..38603940 --- /dev/null +++ b/go/internal/cmc/handlers/attachments/attachments_test.go @@ -0,0 +1,37 @@ +package attachments + +import ( + "testing" + + "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db" +) + +// TestNewAttachmentHandler tests that the handler is created correctly +func TestNewAttachmentHandler(t *testing.T) { + queries := &db.Queries{} + handler := NewAttachmentHandler(queries) + + if handler == nil { + t.Fatal("Expected handler to be created, got nil") + } + + if handler.queries != queries { + t.Fatal("Expected handler.queries to match input queries") + } +} + +// Note: Full integration tests with database mocking would require more setup. +// For now, this provides basic structure validation. +// To run full tests, you would need to: +// 1. Create a test database or use an in-memory SQLite database +// 2. Run migrations +// 3. Test each handler method with actual database calls +// +// Example test structure for future expansion: +// func TestListAttachments(t *testing.T) { +// db := setupTestDB(t) +// defer db.Close() +// queries := db.New(db) +// handler := NewAttachmentHandler(queries) +// // ... test implementation +// } \ No newline at end of file diff --git a/php/app/config/database_local.php b/php/app/config/database_local.php new file mode 100644 index 00000000..d38608bb --- /dev/null +++ b/php/app/config/database_local.php @@ -0,0 +1,14 @@ + 'mysqli', + 'persistent' => false, + 'host' => 'db', + 'login' => 'cmc', + 'password' => 'xVRQI&cA?7AU=hqJ!%au', + 'database' => 'cmc', + 'prefix' => '', + ); +} diff --git a/php/app/controllers/attachments_controller.php b/php/app/controllers/attachments_controller.php index c9935ab9..7ce5ead3 100644 --- a/php/app/controllers/attachments_controller.php +++ b/php/app/controllers/attachments_controller.php @@ -39,20 +39,83 @@ class AttachmentsController extends AppController { function add() { if (!empty($this->data)) { - - $attachment = $this->Attachment->process_attachment($this->data); - if(!$attachment) { - $this->Session->setFlash('The Attachment could not be saved. The filename exists'); - } - else { - $this->Attachment->create(); - - if ($this->Attachment->save($attachment)) { - $this->Session->setFlash(__('The Attachment has been saved', true)); - $this->redirect(array('action'=>'index')); - } else { - $this->Session->setFlash(__('The Attachment could not be saved. Please, try again.', true)); + + // Check if file was uploaded + if (empty($this->data['Attachment']['file']['tmp_name'])) { + $error = 'No file uploaded'; + if (isset($this->data['Attachment']['file']['error'])) { + $errorCodes = array( + UPLOAD_ERR_INI_SIZE => 'File exceeds upload_max_filesize', + UPLOAD_ERR_FORM_SIZE => 'File exceeds MAX_FILE_SIZE', + UPLOAD_ERR_PARTIAL => 'File only partially uploaded', + UPLOAD_ERR_NO_FILE => 'No file was uploaded', + UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder', + UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk', + UPLOAD_ERR_EXTENSION => 'File upload stopped by extension', + ); + $errorCode = $this->data['Attachment']['file']['error']; + $error = isset($errorCodes[$errorCode]) ? $errorCodes[$errorCode] : 'Unknown error: ' . $errorCode; } + $this->Session->setFlash(__('File upload error: ' . $error, true)); + $principles = $this->Attachment->Principle->find('list'); + $this->set(compact('products', 'principles')); + return; + } + + // Proxy the upload request to the Go application + $goHost = getenv('GO_APP_HOST'); + $goUrl = 'http://' . $goHost . '/go/attachments/upload'; + + // Prepare the multipart form data for the Go app + $postFields = array(); + + $postFields['file'] = new CURLFile( + $this->data['Attachment']['file']['tmp_name'], + $this->data['Attachment']['file']['type'], + $this->data['Attachment']['file']['name'] + ); + + if (!empty($this->data['Attachment']['name'])) { + $postFields['name'] = $this->data['Attachment']['name']; + } + + if (!empty($this->data['Attachment']['description'])) { + $postFields['description'] = $this->data['Attachment']['description']; + } + + if (!empty($this->data['Attachment']['principle_id'])) { + $postFields['principle_id'] = $this->data['Attachment']['principle_id']; + } + + // Make the request to Go app + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $goUrl); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, $postFields); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, array( + 'Accept: application/json' + )); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($httpCode == 201) { + $this->Session->setFlash(__('The Attachment has been saved', true)); + $this->redirect(array('action'=>'index')); + } else { + $errorMsg = 'The Attachment could not be saved.'; + if ($curlError) { + $errorMsg .= ' cURL Error: ' . $curlError; + } elseif ($response) { + $errorMsg .= ' Response: ' . $response; + } else { + $errorMsg .= ' HTTP Code: ' . $httpCode; + } + error_log('Attachment upload failed: ' . $errorMsg); + $this->Session->setFlash(__($errorMsg, true)); } } $principles = $this->Attachment->Principle->find('list'); diff --git a/php/app/views/attachments/add.ctp b/php/app/views/attachments/add.ctp index b1cfb51d..e6b22e3e 100644 --- a/php/app/views/attachments/add.ctp +++ b/php/app/views/attachments/add.ctp @@ -1,5 +1,5 @@
-create('Attachment', array('type'=>'file'));?> +create('Attachment', array('type'=>'file', 'url' => array('controller' => 'attachments', 'action' => 'add')));?>
input('name'); echo $form->file('file'); echo $form->input('description'); - echo $form->input('archived'); + // archived not needed for new uploads ?>
end('Submit');?>
+