feat: implement APK versioning and management with upload, update, and download functionalities

This commit is contained in:
Juan Felipe Zapata Moreno 2026-03-11 16:05:15 -06:00
parent 5ec3e6d52a
commit 19af2f4bef
17 changed files with 283 additions and 52 deletions

View File

@ -14,7 +14,7 @@ services:
- DB_DATABASE=${DB_DATABASE} - DB_DATABASE=${DB_DATABASE}
- DB_PORT=${DB_PORT} - DB_PORT=${DB_PORT}
volumes: volumes:
- storage_data:/var/www/repuve-backend-v1/storage - ../../storage:/var/www/repuve-backend-v1/storage
networks: networks:
- repuve-network - repuve-network
mem_limit: 512M mem_limit: 512M
@ -67,8 +67,6 @@ services:
volumes: volumes:
mysql_data: mysql_data:
driver: local driver: local
storage_data:
driver: local
networks: networks:
repuve-network: repuve-network:

View File

@ -17,7 +17,9 @@ RUN apk add --no-cache \
mysql-client \ mysql-client \
libreoffice \ libreoffice \
ttf-dejavu \ ttf-dejavu \
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip && docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip \
&& echo "upload_max_filesize=150M" > /usr/local/etc/php/conf.d/uploads.ini \
&& echo "post_max_size=150M" >> /usr/local/etc/php/conf.d/uploads.ini
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

View File

@ -14,7 +14,7 @@ services:
- DB_DATABASE=${DB_DATABASE} - DB_DATABASE=${DB_DATABASE}
- DB_PORT=${DB_PORT} - DB_PORT=${DB_PORT}
volumes: volumes:
- storage_data:/var/www/repuve-backend-v1/storage - ../../storage:/var/www/repuve-backend-v1/storage
networks: networks:
- repuve-network - repuve-network
mem_limit: 512M mem_limit: 512M
@ -29,7 +29,7 @@ services:
- "127.0.0.1:${NGINX_PORT}:80" - "127.0.0.1:${NGINX_PORT}:80"
volumes: volumes:
- ../../public:/var/www/repuve-backend-v1/public - ../../public:/var/www/repuve-backend-v1/public
- storage_data:/var/www/repuve-backend-v1/storage - ../../storage:/var/www/repuve-backend-v1/storage
- ../../Docker/nginx/nginx.conf:/etc/nginx/nginx.conf - ../../Docker/nginx/nginx.conf:/etc/nginx/nginx.conf
- /var/log/nginx:/var/log/nginx - /var/log/nginx:/var/log/nginx
logging: logging:
@ -65,8 +65,6 @@ services:
volumes: volumes:
mysql_data: mysql_data:
driver: local driver: local
storage_data:
driver: local
networks: networks:
repuve-network: repuve-network:

View File

@ -14,7 +14,9 @@ RUN apk add --no-cache \
bash \ bash \
libreoffice \ libreoffice \
ttf-dejavu \ ttf-dejavu \
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip && docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip \
&& echo "upload_max_filesize=150M" > /usr/local/etc/php/conf.d/uploads.ini \
&& echo "post_max_size=150M" >> /usr/local/etc/php/conf.d/uploads.ini
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

View File

@ -119,12 +119,11 @@ http {
fastcgi_param HTTP_X_REQUEST_ID $request_id; fastcgi_param HTTP_X_REQUEST_ID $request_id;
} }
client_max_body_size 20M; client_max_body_size 150M;
# Handle storage files (Laravel storage link) # Handle storage files (Laravel storage link)
location /storage { location /storage/ {
alias /var/www/repuve-backend-v1/storage/app/public; alias /var/www/repuve-backend-v1/storage/app/public/;
try_files $uri =404;
} }
location /profile { location /profile {

View File

@ -4,61 +4,140 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Repuve\ApkStorageRequest; use App\Http\Requests\Repuve\ApkStorageRequest;
use App\Http\Requests\Repuve\ApkUpdateRequest;
use App\Models\ApkLog;
use App\Models\ApkVersion;
use Illuminate\Routing\Controllers\HasMiddleware;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Notsoweb\ApiResponse\Enums\ApiResponse; use Notsoweb\ApiResponse\Enums\ApiResponse;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class AppController extends Controller class AppController extends Controller implements HasMiddleware
{ {
/** /**
* Middleware * Middleware
*/ */
public static function middleware(): array public static function middleware(): array
{ {
return [ return [
self::can('apk.upload', ['upload']), self::can('apk.index', ['index']),
self::can('apk.edit', ['update']),
self::can('apk.destroy', ['destroy']),
self::can('apk.download', ['download']), self::can('apk.download', ['download']),
]; ];
} }
/** /**
* Subir APK de la aplicación móvil * Listar versiones: actual, 2 anteriores y las no disponibles
*/ */
public function upload(ApkStorageRequest $request) public function index()
{ {
$file = $request->file('apk'); $latestId = ApkVersion::latest()->value('id');
// Eliminar APK anterior si existe $versions = ApkVersion::with('uploader:id,name')
$existingFiles = Storage::disk('public')->files('apk'); ->withCount('logs')
foreach ($existingFiles as $existingFile) { ->latest()
Storage::disk('public')->delete($existingFile); ->get()
} ->map(function ($version) use ($latestId) {
$version->is_current = $version->id === $latestId;
// Guardar el nuevo APK $version->available = $version->id === $latestId;
$fileName = 'repuve-app-' . now()->format('Y-m-d') . '.apk'; return $version;
$file->storeAs('apk', $fileName, 'public'); });
return ApiResponse::OK->response([ return ApiResponse::OK->response([
'message' => 'APK subido correctamente', 'current' => $versions->where('is_current', true)->first(),
'file' => $fileName, 'recent' => $versions->where('is_current', false)->take(2)->values(),
'unavailable' => $versions->where('is_current', false)->skip(2)->values(),
]); ]);
} }
/** /**
* Descargar APK de la aplicación móvil * Subir nueva versión del APK
*/ */
public function download(): BinaryFileResponse public function store(ApkStorageRequest $request)
{ {
$files = Storage::disk('public')->files('apk'); $file = $request->file('apk');
if (empty($files)) { $fileName = 'repuve-app-' . now()->format('Y-m-d') . '.apk';
// Eliminar APK anterior del storage
foreach (Storage::disk('public')->files('apk') as $existing) {
Storage::disk('public')->delete($existing);
}
$path = $file->storeAs('apk', $fileName, 'public');
ApkVersion::create([
'file_name' => $fileName,
'path' => $path,
'changelog' => $request->input('changelog'),
'uploaded_by' => auth()->id(),
]);
return ApiResponse::OK->response([
'message' => 'APK subido correctamente',
'file' => $fileName,
]);
}
/**
* Actualizar nombre y/o changelog de una versión
*/
public function update(ApkUpdateRequest $request, ApkVersion $app)
{
$data = $request->only(['changelog', 'file_name']);
if ($request->filled('file_name') && $request->input('file_name') !== $app->file_name) {
$newFileName = $request->input('file_name');
$newPath = 'apk/' . $newFileName;
if (Storage::disk('public')->exists($newPath)) {
return ApiResponse::BAD_REQUEST->response([
'message' => 'Ya existe un archivo con ese nombre.',
]);
}
Storage::disk('public')->move($app->path, $newPath);
$data['file_name'] = $newFileName;
$data['path'] = $newPath;
}
$app->update($data);
return ApiResponse::OK->response([
'message' => 'Versión actualizada correctamente',
]);
}
/**
* Eliminar una versión
*/
public function destroy(ApkVersion $app)
{
Storage::disk('public')->delete($app->path);
$app->delete();
return ApiResponse::OK->response([
'message' => 'Versión eliminada correctamente',
]);
}
/**
* Descargar APK más reciente y registrar log
*/
public function download()
{
$version = ApkVersion::latest()->first();
if (!$version) {
abort(404, 'No hay APK disponible para descargar.'); abort(404, 'No hay APK disponible para descargar.');
} }
$latestFile = end($files); ApkLog::create([
$path = Storage::disk('public')->path($latestFile); 'apk_version_id' => $version->id,
'downloaded_by' => auth()->id(),
]);
return response()->download($path); return redirect(asset('storage/' . $version->path));
} }
} }

View File

@ -14,7 +14,7 @@ public function authorize()
public function rules() public function rules()
{ {
return [ return [
'apk' => 'required|file|max:102400', 'apk' => 'required|file|max:153600',
]; ];
} }
@ -23,7 +23,7 @@ public function messages()
return [ return [
'apk.required' => 'El archivo APK es obligatorio', 'apk.required' => 'El archivo APK es obligatorio',
'apk.file' => 'El archivo debe ser un archivo válido', 'apk.file' => 'El archivo debe ser un archivo válido',
'apk.max' => 'El archivo no debe superar los 100MB', 'apk.max' => 'El archivo no debe superar los 150MB',
]; ];
} }
} }

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Repuve;
use Illuminate\Foundation\Http\FormRequest;
class ApkUpdateRequest extends FormRequest
{
public function authorize()
{
return auth()->user()->can('apk.edit');
}
public function rules()
{
return [
'file_name' => 'nullable|string|max:255',
'changelog' => 'nullable|string|max:255',
];
}
public function messages()
{
return [
'file_name.string' => 'El nombre del archivo debe ser una cadena de texto',
'file_name.max' => 'El nombre del archivo no debe superar los 255 caracteres',
'changelog.string' => 'El changelog debe ser una cadena de texto',
'changelog.max' => 'El changelog no debe superar los 255 caracteres',
];
}
}

32
app/Models/ApkLog.php Normal file
View File

@ -0,0 +1,32 @@
<?php namespace App\Models;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use Illuminate\Database\Eloquent\Model;
/**
* Descripción
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @version 1.0.0
*/
class ApkLog extends Model
{
protected $fillable = [
'apk_version_id',
'downloaded_by',
];
public function apkVersion()
{
return $this->belongsTo(ApkVersion::class, 'apk_version_id');
}
public function downloader()
{
return $this->belongsTo(User::class, 'downloaded_by');
}
}

25
app/Models/ApkVersion.php Normal file
View File

@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ApkVersion extends Model
{
protected $fillable = [
'file_name',
'uploaded_by',
'changelog',
'path',
];
public function uploader()
{
return $this->belongsTo(User::class, 'uploaded_by');
}
public function logs()
{
return $this->hasMany(ApkLog::class, 'apk_version_id');
}
}

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('apk_versions', function (Blueprint $table) {
$table->id();
$table->string('file_name');
$table->string('path')->nullable();
$table->text('changelog')->nullable();
$table->foreignId('uploaded_by')->constrained('users')->onDelete('cascade');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('apk_versions');
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('apk_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('apk_version_id')->constrained('apk_versions')->onDelete('cascade');
$table->foreignId('downloaded_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('apk_logs');
}
};

View File

@ -55,7 +55,6 @@ public function up(): void
*/ */
public function down(): void public function down(): void
{ {
// No se puede revertir la eliminación de datos sin un backup //
// Si necesitas revertir, restaura desde un backup de la base de datos
} }
}; };

View File

@ -11,6 +11,7 @@
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Notsoweb\LaravelCore\Traits\MySql\RolePermission; use Notsoweb\LaravelCore\Traits\MySql\RolePermission;
use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Permission;
use Spatie\Permission\PermissionRegistrar;
/** /**
* Roles y permisos * Roles y permisos
@ -53,7 +54,9 @@ public function run(): void
// === APK === // === APK ===
$apk = PermissionType::updateOrCreate(['name' => 'App Móvil']); $apk = PermissionType::updateOrCreate(['name' => 'App Móvil']);
$apkUpload = $this->onPermission('apk.upload', 'Subir APK de la aplicación móvil', $apk, 'api'); $apkIndex = $this->onPermission('apk.index', 'Historial de registros apk', $apk, 'api');
$apkEdit = $this->onPermission('apk.edit', 'Actualizar registro de apk', $apk, 'api');
$apkDestroy = $this->onPermission('apk.destroy', 'Eliminar registro de apk', $apk, 'api');
$apkDownload = $this->onPermission('apk.download', 'Descargar APK de la aplicación móvil', $apk, 'api'); $apkDownload = $this->onPermission('apk.download', 'Descargar APK de la aplicación móvil', $apk, 'api');
// === MÓDULOS === // === MÓDULOS ===
@ -190,7 +193,7 @@ public function run(): void
// Constancias // Constancias
$tagIndex, $tagCreate, $tagEdit, $tagDestroy, $tagIndex, $tagCreate, $tagEdit, $tagDestroy,
//app //app
$apkUpload, $apkDownload, $apkIndex, $apkEdit, $apkDestroy, $apkDownload,
); );
// Encargado // Encargado

View File

@ -61,8 +61,8 @@ php artisan storage:link --force || true
echo "Ejecutando configuración de desarrollo..." echo "Ejecutando configuración de desarrollo..."
composer run env:dev composer run env:dev
echo "Creando directorio de claves Passport..." echo "Creando directorios necesarios..."
mkdir -p storage/app/keys mkdir -p storage/app/keys storage/app/public/apk
echo "Generando claves de Passport..." echo "Generando claves de Passport..."
php artisan passport:keys --force || true php artisan passport:keys --force || true

View File

@ -71,8 +71,8 @@ php artisan view:cache
echo "Generando caché de eventos..." echo "Generando caché de eventos..."
php artisan event:cache php artisan event:cache
echo "Creando directorio de claves Passport..." echo "Creando directorios necesarios..."
mkdir -p storage/app/keys mkdir -p storage/app/keys storage/app/public/apk
if [ ! -f "storage/app/keys/oauth-private.key" ] || [ ! -f "storage/app/keys/oauth-public.key" ]; then if [ ! -f "storage/app/keys/oauth-private.key" ] || [ ! -f "storage/app/keys/oauth-public.key" ]; then
echo "Claves de Passport no encontradas, generando..." echo "Claves de Passport no encontradas, generando..."

View File

@ -94,8 +94,11 @@
Route::post('repuve-credentials/decrypt', [SettingsController::class, 'decrypt']); Route::post('repuve-credentials/decrypt', [SettingsController::class, 'decrypt']);
// Rutas App móvil // Rutas App móvil
Route::get('app', [AppController::class, 'index']);
Route::post('app', [AppController::class, 'store']);
Route::put('app/{app}', [AppController::class, 'update']);
Route::delete('app/{app}', [AppController::class, 'destroy']);
Route::get('app/download', [AppController::class, 'download']); Route::get('app/download', [AppController::class, 'download']);
Route::post('app/upload', [AppController::class, 'upload']);
}); });