Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
72ce00c
feat: create secret_attachments table migration with tests
kevalyq Nov 16, 2025
c339e9d
feat: add SecretAttachment model with encryption
kevalyq Nov 16, 2025
30af79d
feat: add AttachmentStorageService with tenant encryption
kevalyq Nov 16, 2025
c0699a1
feat: add attachments relationship to Secret model
kevalyq Nov 16, 2025
1eed66c
feat: add SecretAttachmentPolicy with owner authorization
kevalyq Nov 16, 2025
f08075b
feat: add SecretAttachmentController with 4 endpoints
kevalyq Nov 16, 2025
edb17bf
fix: improve attachment validation and SPDX headers
kevalyq Nov 16, 2025
c504d69
fix: update AttachmentStorageService SPDX header
kevalyq Nov 16, 2025
a9a6954
fix: update SecretAttachmentPolicy SPDX header
kevalyq Nov 16, 2025
266ff3a
docs: update CHANGELOG for File Attachments API
kevalyq Nov 16, 2025
a86e31d
style: format CHANGELOG.md with Prettier
kevalyq Nov 16, 2025
120d56d
fix: resolve PHPStan Level Max type safety issues
kevalyq Nov 16, 2025
747d5a1
fix: resolve remaining PHPStan type safety issues
kevalyq Nov 16, 2025
31ead28
fix: add type annotation for validated array in Controller
kevalyq Nov 16, 2025
cc14df6
wip: SecretAttachment Model tests need fixing
kevalyq Nov 16, 2025
47aef01
style: fix Laravel Pint formatting issues
kevalyq Nov 16, 2025
fdf15f8
fix: use explicit property assignment pattern in SecretAttachment tests
kevalyq Nov 16, 2025
b44b434
fix: correct download_url accessor to include /api prefix
kevalyq Nov 16, 2025
7fb86c1
fix: use /v1/ prefix in download_url accessor (not /api/v1/)
kevalyq Nov 16, 2025
c6262b1
fix: add Attribute import and PHPStan type annotation for downloadUrl
kevalyq Nov 16, 2025
771685a
fix: correct test expectation to use /v1/ instead of /api/v1/
kevalyq Nov 16, 2025
03da849
fix: remove duplicate old-style download_url accessor
kevalyq Nov 16, 2025
c07384c
fix: address critical Copilot review comments (storage disk, checksum…
kevalyq Nov 16, 2025
58ee4c0
fix: address Copilot review nitpicks (docs, helpers, N+1, file check)
kevalyq Nov 16, 2025
e327e1f
style: fix Pint blank_line_before_statement in Secret.php
kevalyq Nov 16, 2025
948cd20
refactor: implement API Resources and Form Requests for SecretAttachment
kevalyq Nov 16, 2025
7c09df9
style: fix phpdoc_separation in SecretAttachmentResource
kevalyq Nov 16, 2025
60cc525
fix: address Copilot review comments
kevalyq Nov 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **File Attachments API (Phase 2)** (#175)
- Upload encrypted file attachments to secrets (POST `/v1/secrets/{secret}/attachments`)
- List attachments for a secret (GET `/v1/secrets/{secret}/attachments`)
- Download decrypted attachments (GET `/v1/attachments/{attachment}/download`)
- Delete attachments (DELETE `/v1/attachments/{attachment}`)
- Files encrypted at rest using tenant DEK encryption
- Configurable file size limits and MIME type restrictions
- Owner-based authorization via `SecretAttachmentPolicy`
- OpenAPI documentation for all attachment endpoints
- Comprehensive test coverage: 13 Controller tests, 3 Service tests, 2 Model tests, 8 Policy tests

- **Code Coverage Integration** (#170)
- Integrated Codecov for automated coverage tracking
- PHPUnit now generates Clover XML coverage reports
Expand Down
107 changes: 107 additions & 0 deletions app/Http/Controllers/Api/V1/SecretAttachmentController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

/*
* SPDX-FileCopyrightText: 2025 SecPal Contributors
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Requests\StoreSecretAttachmentRequest;
use App\Http\Resources\SecretAttachmentResource;
use App\Models\Secret;
use App\Models\SecretAttachment;
use App\Models\User;
use App\Services\AttachmentStorageService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Gate;

/**
* Secret attachment API controller.
*
* Handles file upload, listing, download, and deletion for secret attachments.
* All files are encrypted at rest using tenant DEK.
*/
class SecretAttachmentController extends Controller
{
/**
* Create controller instance.
*/
public function __construct(
private readonly AttachmentStorageService $storageService
) {}

/**
* Upload attachment to secret.
*/
public function store(StoreSecretAttachmentRequest $request, Secret $secret): JsonResponse
{
Gate::authorize('create', [SecretAttachment::class, $secret]);

/** @var \Illuminate\Http\UploadedFile $file */
$file = $request->validated()['file'];

$user = $request->user();
assert($user instanceof User, 'User must be authenticated');

$attachment = $this->storageService->store($file, $secret, $user);

return SecretAttachmentResource::make($attachment)
->response()
->setStatusCode(201);
}

/**
* List attachments for secret.
*/
public function index(Secret $secret): JsonResponse
{
Gate::authorize('viewAny', [SecretAttachment::class, $secret]);

$attachments = $secret->attachments()->latest()->get();

return response()->json([
'data' => SecretAttachmentResource::collection($attachments),
]);
}

/**
* Download attachment.
*/
public function download(SecretAttachment $attachment): Response
{
Gate::authorize('view', $attachment);

$content = $this->storageService->retrieve($attachment);
$filename = $attachment->filename_plain;

// Ensure filename is present; attachments should always have a filename.
if ($filename === null) {
abort(500, 'Attachment is missing a filename.');
}

// Escape filename for Content-Disposition header (RFC 2231/5987)
$safeFilename = str_replace(['"', '\\'], ['', ''], $filename);

return response($content, 200, [
'Content-Type' => $attachment->mime_type,
'Content-Disposition' => 'attachment; filename="'.$safeFilename.'"',
'Content-Length' => (string) $attachment->file_size,
]);
}

/**
* Delete attachment.
*/
public function destroy(SecretAttachment $attachment): Response
{
Gate::authorize('delete', $attachment);

$this->storageService->delete($attachment);

return response()->noContent();
}
}
67 changes: 67 additions & 0 deletions app/Http/Requests/StoreSecretAttachmentRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

// SPDX-FileCopyrightText: 2025 SecPal Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

/**
* Form Request for storing SecretAttachment.
*
* Validates file uploads for secret attachments.
*/
class StoreSecretAttachmentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* Authorization is handled by SecretAttachmentPolicy, not here.
*/
public function authorize(): bool
{
return true;
}

/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
/** @var int $maxSize */
$maxSize = config('attachments.max_file_size');
/** @var array<int, string> $allowedMimes */
$allowedMimes = config('attachments.allowed_mime_types');

return [
'file' => [
'required',
'file',
'max:'.($maxSize / 1024), // Convert bytes to KB for Laravel validation
'mimetypes:'.implode(',', $allowedMimes),
],
];
}

/**
* Get custom error messages for validation rules.
*
* @return array<string, string>
*/
public function messages(): array
{
/** @var int $maxSizeBytes */
$maxSizeBytes = config('attachments.max_file_size');
$maxSize = $maxSizeBytes / (1024 * 1024); // Convert bytes to MB

return [
'file.required' => 'Please select a file to upload.',
'file.file' => 'The uploaded file is not valid.',
'file.max' => 'The file size must not exceed '.$maxSize.' MB.',
'file.mimetypes' => 'The file type is not allowed. Only certain file types are permitted.',
];
}
}
42 changes: 42 additions & 0 deletions app/Http/Resources/SecretAttachmentResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

// SPDX-FileCopyrightText: 2025 SecPal Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

/**
* API Resource for SecretAttachment model.
*
* Transforms attachment metadata for JSON responses.
*
* @property \App\Models\SecretAttachment $resource
*
* @mixin \App\Models\SecretAttachment
*/
class SecretAttachmentResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
/** @var \App\Models\SecretAttachment $attachment */
$attachment = $this->resource;

return [
'id' => $attachment->id,
'filename' => $attachment->filename_plain,
'file_size' => $attachment->file_size,
'mime_type' => $attachment->mime_type,
'download_url' => $attachment->download_url,
'uploaded_by' => $attachment->uploaded_by,
'created_at' => $attachment->created_at->toIso8601String(),
];
}
}
33 changes: 33 additions & 0 deletions app/Models/Secret.php
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,39 @@ public function owner(): BelongsTo
return $this->belongsTo(User::class, 'owner_id');
}

/**
* Relation to SecretAttachment (file attachments).
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany<SecretAttachment, $this>
*/
public function attachments(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(SecretAttachment::class);
}

/**
* Get count of attachments for this secret.
*
* Uses the aggregated count from withCount('attachments') if available,
* otherwise performs a count query. To avoid N+1 queries, use:
* Secret::withCount('attachments')->get()
*
* @return int Number of attachments
*/
public function getAttachmentCountAttribute(): int
{
// Use aggregated count if available (from withCount())
if (isset($this->attributes['attachments_count'])) {
/** @var int|numeric-string $count */
$count = $this->attributes['attachments_count'];

return (int) $count;
}

// Fallback to query (may cause N+1 if used in a loop)
return $this->attachments()->count();
}

/**
* Scope to filter by owner.
*
Expand Down
Loading