Jelajahi Sumber

add documents

ljoaquim 3 minggu lalu
induk
melakukan
d627fff206

+ 1 - 0
.gitignore

@@ -7,3 +7,4 @@ package-lock.json
 node_modules
 319245319-320109623_PROD.cer
 319245319-320109623_PROD.key
+data/*

+ 77 - 0
controllers/DocumentDownloadController.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace Controllers;
+
+use Libs\ResponseLib;
+use Models\DocumentModel;
+use Psr\Http\Message\ServerRequestInterface;
+use React\Http\Message\Response;
+use Respect\Validation\Exceptions\ValidationException;
+use Respect\Validation\Validator as val;
+
+class DocumentDownloadController
+{
+    private DocumentModel $documentModel;
+
+    public function __construct()
+    {
+        $this->documentModel = new DocumentModel();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $userId = (int)($request->getAttribute('api_user_id') ?? 0);
+        $companyId = (int)($request->getAttribute('api_company_id') ?? 0);
+
+        if ($userId <= 0 || $companyId <= 0) {
+            return ResponseLib::sendFail('Unauthorized', [], 'E_VALIDATE')->withStatus(401);
+        }
+
+        $body = json_decode((string)$request->getBody(), true) ?? [];
+
+        try {
+            val::key('document_type', val::stringType()->notEmpty()->length(1, 255))
+                ->assert($body);
+        } catch (ValidationException $e) {
+            return ResponseLib::sendFail('Validation failed: ' . $e->getFullMessage(), [], 'E_VALIDATE')->withStatus(400);
+        }
+
+        $documentType = (string)$body['document_type'];
+
+        try {
+            $doc = $this->documentModel->findLatestByUserIdAndType($userId, $documentType);
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail('Database error: ' . $e->getMessage(), [], 'E_DATABASE')->withStatus(500);
+        }
+
+        if (!$doc) {
+            return ResponseLib::sendFail('Document not found', [], 'E_NOT_FOUND')->withStatus(404);
+        }
+
+        $path = (string)($doc['document_path'] ?? '');
+        if ($path === '' || !is_file($path)) {
+            return ResponseLib::sendFail('Document file not found', [], 'E_NOT_FOUND')->withStatus(404);
+        }
+
+        $filename = basename($path);
+        $content = @file_get_contents($path);
+        if ($content === false) {
+            return ResponseLib::sendFail('Failed to read document file', [], 'E_INTERNAL')->withStatus(500);
+        }
+
+        $contentType = 'application/octet-stream';
+        $ext = strtolower((string)pathinfo($filename, PATHINFO_EXTENSION));
+        if ($ext === 'pdf') {
+            $contentType = 'application/pdf';
+        }
+
+        return new Response(
+            200,
+            [
+                'Content-Type' => $contentType,
+                'Content-Disposition' => 'attachment; filename="' . $filename . '"',
+            ],
+            $content
+        );
+    }
+}

+ 35 - 0
controllers/DocumentListController.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace Controllers;
+
+use Libs\ResponseLib;
+use Models\DocumentModel;
+use Psr\Http\Message\ServerRequestInterface;
+
+class DocumentListController
+{
+    private DocumentModel $documentModel;
+
+    public function __construct()
+    {
+        $this->documentModel = new DocumentModel();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $userId = (int)($request->getAttribute('api_user_id') ?? 0);
+        $companyId = (int)($request->getAttribute('api_company_id') ?? 0);
+
+        if ($userId <= 0 || $companyId <= 0) {
+            return ResponseLib::sendFail('Unauthorized', [], 'E_VALIDATE')->withStatus(401);
+        }
+
+        try {
+            $documents = $this->documentModel->listByUserId($userId);
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail('Database error: ' . $e->getMessage(), [], 'E_DATABASE')->withStatus(500);
+        }
+
+        return ResponseLib::sendOk(['documents' => $documents], 'S_OK');
+    }
+}

+ 67 - 0
controllers/DocumentUploadController.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace Controllers;
+
+use Libs\MultipartFormDataParser;
+use Libs\ResponseLib;
+use Models\DocumentModel;
+use Psr\Http\Message\ServerRequestInterface;
+use Services\DocumentStorageService;
+
+class DocumentUploadController
+{
+    private DocumentModel $documentModel;
+    private DocumentStorageService $storage;
+
+    public function __construct()
+    {
+        $this->documentModel = new DocumentModel();
+        $this->storage = new DocumentStorageService();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $userId = (int)($request->getAttribute('api_user_id') ?? 0);
+        $companyId = (int)($request->getAttribute('api_company_id') ?? 0);
+
+        if ($userId <= 0 || $companyId <= 0) {
+            return ResponseLib::sendFail('Unauthorized', [], 'E_VALIDATE')->withStatus(401);
+        }
+
+        try {
+            $parsed = MultipartFormDataParser::parse($request);
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail('Invalid multipart form-data: ' . $e->getMessage(), [], 'E_VALIDATE')->withStatus(400);
+        }
+
+        $fields = $parsed['fields'] ?? [];
+        $files = $parsed['files'] ?? [];
+
+        $documentType = isset($fields['document_type']) ? (string)$fields['document_type'] : '';
+        if ($documentType === '') {
+            return ResponseLib::sendFail('Missing field: document_type', [], 'E_VALIDATE')->withStatus(400);
+        }
+
+        $file = $files['file'] ?? null;
+        if (!is_array($file) || !isset($file['content'])) {
+            return ResponseLib::sendFail('Missing file field: file', [], 'E_VALIDATE')->withStatus(400);
+        }
+
+        $originalFilename = (string)($file['filename'] ?? 'upload.bin');
+        $contentType = (string)($file['content_type'] ?? 'application/octet-stream');
+        $content = (string)$file['content'];
+
+        try {
+            $documentType = $this->storage->sanitizeDocumentType($documentType);
+            $dir = $this->storage->ensureDirectory($companyId, $userId, $documentType);
+            $storedFilename = $this->storage->buildStoredFilename($originalFilename, $contentType);
+            $storedPath = $this->storage->writeFile($dir, $storedFilename, $content);
+
+            $created = $this->documentModel->create($userId, $documentType, $storedPath);
+
+            return ResponseLib::sendOk($created, 'S_CREATED');
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail('Upload failed: ' . $e->getMessage(), [], 'E_INTERNAL')->withStatus(500);
+        }
+    }
+}

+ 85 - 0
libs/MultipartFormDataParser.php

@@ -0,0 +1,85 @@
+<?php
+
+namespace Libs;
+
+use Psr\Http\Message\ServerRequestInterface;
+
+class MultipartFormDataParser
+{
+    public static function parse(ServerRequestInterface $request): array
+    {
+        $contentType = $request->getHeaderLine('Content-Type');
+        if (!preg_match('/multipart\/form-data;\s*boundary=(?:(?:"([^"]+)")|([^;\s]+))/i', $contentType, $m)) {
+            throw new \RuntimeException('Invalid multipart Content-Type');
+        }
+
+        $boundary = $m[1] !== '' ? $m[1] : $m[2];
+        $rawBody = (string)$request->getBody();
+        if ($rawBody === '') {
+            $rawBody = (string)file_get_contents('php://input');
+        }
+
+        $delimiter = "--" . $boundary;
+        $parts = explode($delimiter, $rawBody);
+
+        $fields = [];
+        $files = [];
+
+        foreach ($parts as $part) {
+            $part = ltrim($part, "\r\n");
+            $part = rtrim($part, "\r\n");
+
+            if ($part === '' || $part === '--') {
+                continue;
+            }
+
+            $split = preg_split("/\r\n\r\n/", $part, 2);
+            if (!$split || count($split) !== 2) {
+                continue;
+            }
+
+            [$rawHeaders, $body] = $split;
+            $headers = self::parseHeaders($rawHeaders);
+
+            $cd = $headers['content-disposition'] ?? '';
+            if (!preg_match('/name="([^"]+)"/i', $cd, $nm)) {
+                continue;
+            }
+
+            $name = $nm[1];
+            $filename = null;
+            if (preg_match('/filename="([^"]*)"/i', $cd, $fm)) {
+                $filename = $fm[1];
+            }
+
+            $body = preg_replace("/\r\n\z/", '', $body);
+
+            if ($filename !== null && $filename !== '') {
+                $files[$name] = [
+                    'field' => $name,
+                    'filename' => $filename,
+                    'content_type' => $headers['content-type'] ?? 'application/octet-stream',
+                    'content' => $body,
+                ];
+            } else {
+                $fields[$name] = $body;
+            }
+        }
+
+        return ['fields' => $fields, 'files' => $files];
+    }
+
+    private static function parseHeaders(string $rawHeaders): array
+    {
+        $headers = [];
+        foreach (preg_split('/\r\n/', $rawHeaders) as $line) {
+            $line = trim($line);
+            if ($line === '' || strpos($line, ':') === false) {
+                continue;
+            }
+            [$k, $v] = explode(':', $line, 2);
+            $headers[strtolower(trim($k))] = trim($v);
+        }
+        return $headers;
+    }
+}

+ 27 - 0
migrations/20260223_add_document_table.sql

@@ -0,0 +1,27 @@
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS "document" (
+  "document_id" SERIAL PRIMARY KEY,
+  "user_id" INTEGER NOT NULL,
+  "document_type" TEXT NOT NULL,
+  "document_path" TEXT NOT NULL
+);
+
+DO $$
+BEGIN
+  IF NOT EXISTS (
+    SELECT 1
+    FROM pg_constraint
+    WHERE conname = 'document_user_id_fkey'
+  ) THEN
+    ALTER TABLE "document"
+      ADD CONSTRAINT document_user_id_fkey
+      FOREIGN KEY ("user_id") REFERENCES "user"("user_id")
+      ON DELETE CASCADE;
+  END IF;
+END $$;
+
+CREATE INDEX IF NOT EXISTS idx_document_user_id ON "document"("user_id");
+CREATE INDEX IF NOT EXISTS idx_document_user_id_type ON "document"("user_id", "document_type");
+
+COMMIT;

+ 70 - 0
models/DocumentModel.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace Models;
+
+class DocumentModel
+{
+    private \PDO $pdo;
+
+    public function __construct()
+    {
+        if (isset($GLOBALS['pdo']) && $GLOBALS['pdo'] instanceof \PDO) {
+            $this->pdo = $GLOBALS['pdo'];
+            return;
+        }
+    }
+
+    public function create(int $userId, string $documentType, string $documentPath): array
+    {
+        $stmt = $this->pdo->prepare(
+            'INSERT INTO "document" (user_id, document_type, document_path)
+             VALUES (:user_id, :document_type, :document_path)
+             RETURNING document_id'
+        );
+
+        $stmt->execute([
+            'user_id' => $userId,
+            'document_type' => $documentType,
+            'document_path' => $documentPath,
+        ]);
+
+        $documentId = (int)$stmt->fetchColumn();
+
+        return [
+            'document_id' => $documentId,
+            'user_id' => $userId,
+            'document_type' => $documentType,
+            'document_path' => $documentPath,
+        ];
+    }
+
+    public function listByUserId(int $userId): array
+    {
+        $stmt = $this->pdo->prepare(
+            'SELECT document_id, user_id, document_type, document_path
+             FROM "document"
+             WHERE user_id = :user_id
+             ORDER BY document_id DESC'
+        );
+        $stmt->execute(['user_id' => $userId]);
+        return $stmt->fetchAll(\PDO::FETCH_ASSOC);
+    }
+
+    public function findLatestByUserIdAndType(int $userId, string $documentType): ?array
+    {
+        $stmt = $this->pdo->prepare(
+            'SELECT document_id, user_id, document_type, document_path
+             FROM "document"
+             WHERE user_id = :user_id AND document_type = :document_type
+             ORDER BY document_id DESC
+             LIMIT 1'
+        );
+        $stmt->execute([
+            'user_id' => $userId,
+            'document_type' => $documentType,
+        ]);
+
+        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+        return $row ?: null;
+    }
+}

+ 5 - 0
public/index.php

@@ -91,6 +91,11 @@ $app->post('/orderbook/cancel', $authJwt, \Controllers\OrderbookUpdateStatusCont
 $app->post('/orderbook/transfer', $authJwt, \Controllers\OrderbookTransferController::class);
 $app->post('/harvest/list', $authJwt, \Controllers\HarvestListController::class);
 
+// Documents (JWT-protected)
+$app->post('/documents/list', $authJwt, \Controllers\DocumentListController::class);
+$app->post('/documents/upload', $authJwt, \Controllers\DocumentUploadController::class);
+$app->post('/documents/download', $authJwt, \Controllers\DocumentDownloadController::class);
+
 $app->post('/discount/get', $authJwt, $onlyCompany1Or2, \Controllers\DiscountGetController::class);
 $app->post('/discount/create', $authJwt, $onlyCompany1Or2, \Controllers\DiscountCreateController::class);
 $app->post('/discount/delete', $authJwt, $onlyCompany1Or2, \Controllers\DiscountDeleteController::class);

+ 72 - 0
services/DocumentStorageService.php

@@ -0,0 +1,72 @@
+<?php
+
+namespace Services;
+
+class DocumentStorageService
+{
+    private string $rootDir;
+
+    public function __construct(?string $rootDir = null)
+    {
+        $this->rootDir = $rootDir ?? ($_ENV['DOCUMENTS_ROOT'] ?? '/data/documents');
+    }
+
+    public function sanitizeDocumentType(string $documentType): string
+    {
+        $documentType = trim($documentType);
+        $documentType = preg_replace('/[^a-zA-Z0-9._-]+/', '_', $documentType);
+        $documentType = trim($documentType, '._-');
+
+        if ($documentType === '') {
+            throw new \RuntimeException('Invalid document_type');
+        }
+
+        return $documentType;
+    }
+
+    public function ensureDirectory(int $companyId, int $userId, string $documentType): string
+    {
+        $documentType = $this->sanitizeDocumentType($documentType);
+
+        $dir = rtrim($this->rootDir, '/');
+        $dir .= '/' . $companyId;
+        $dir .= '/' . $userId;
+        $dir .= '/' . $documentType;
+
+        if (!is_dir($dir)) {
+            if (!@mkdir($dir, 0775, true) && !is_dir($dir)) {
+                throw new \RuntimeException('Failed to create documents directory');
+            }
+        }
+
+        return $dir;
+    }
+
+    public function buildStoredFilename(string $originalFilename, ?string $contentType = null): string
+    {
+        $ext = pathinfo($originalFilename, PATHINFO_EXTENSION);
+        $ext = $ext ? strtolower($ext) : '';
+
+        if ($ext === '' && $contentType) {
+            if (stripos($contentType, 'pdf') !== false) {
+                $ext = 'pdf';
+            }
+        }
+
+        $name = bin2hex(random_bytes(16));
+        return $ext !== '' ? ($name . '.' . $ext) : $name;
+    }
+
+    public function writeFile(string $dir, string $filename, string $content): string
+    {
+        $dir = rtrim($dir, '/');
+        $path = $dir . '/' . $filename;
+
+        $bytes = @file_put_contents($path, $content, LOCK_EX);
+        if ($bytes === false) {
+            throw new \RuntimeException('Failed to write file');
+        }
+
+        return $path;
+    }
+}