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_PORT=${DB_PORT}
volumes:
- storage_data:/var/www/repuve-backend-v1/storage
- ../../storage:/var/www/repuve-backend-v1/storage
networks:
- repuve-network
mem_limit: 512M
@ -67,8 +67,6 @@ services:
volumes:
mysql_data:
driver: local
storage_data:
driver: local
networks:
repuve-network:

View File

@ -17,7 +17,9 @@ RUN apk add --no-cache \
mysql-client \
libreoffice \
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

View File

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

View File

@ -14,7 +14,9 @@ RUN apk add --no-cache \
bash \
libreoffice \
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

View File

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

View File

@ -4,40 +4,74 @@
use App\Http\Controllers\Controller;
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 Notsoweb\ApiResponse\Enums\ApiResponse;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class AppController extends Controller
class AppController extends Controller implements HasMiddleware
{
/**
* Middleware
*/
public static function middleware(): array
{
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']),
];
}
/**
* 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()
{
$latestId = ApkVersion::latest()->value('id');
$versions = ApkVersion::with('uploader:id,name')
->withCount('logs')
->latest()
->get()
->map(function ($version) use ($latestId) {
$version->is_current = $version->id === $latestId;
$version->available = $version->id === $latestId;
return $version;
});
return ApiResponse::OK->response([
'current' => $versions->where('is_current', true)->first(),
'recent' => $versions->where('is_current', false)->take(2)->values(),
'unavailable' => $versions->where('is_current', false)->skip(2)->values(),
]);
}
/**
* Subir nueva versión del APK
*/
public function store(ApkStorageRequest $request)
{
$file = $request->file('apk');
// Eliminar APK anterior si existe
$existingFiles = Storage::disk('public')->files('apk');
foreach ($existingFiles as $existingFile) {
Storage::disk('public')->delete($existingFile);
$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);
}
// Guardar el nuevo APK
$fileName = 'repuve-app-' . now()->format('Y-m-d') . '.apk';
$file->storeAs('apk', $fileName, 'public');
$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',
@ -46,19 +80,64 @@ public function upload(ApkStorageRequest $request)
}
/**
* Descargar APK de la aplicación móvil
* Actualizar nombre y/o changelog de una versión
*/
public function download(): BinaryFileResponse
public function update(ApkUpdateRequest $request, ApkVersion $app)
{
$files = Storage::disk('public')->files('apk');
$data = $request->only(['changelog', 'file_name']);
if (empty($files)) {
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.');
}
$latestFile = end($files);
$path = Storage::disk('public')->path($latestFile);
ApkLog::create([
'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()
{
return [
'apk' => 'required|file|max:102400',
'apk' => 'required|file|max:153600',
];
}
@ -23,7 +23,7 @@ public function messages()
return [
'apk.required' => 'El archivo APK es obligatorio',
'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
{
// 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 Notsoweb\LaravelCore\Traits\MySql\RolePermission;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\PermissionRegistrar;
/**
* Roles y permisos
@ -53,7 +54,9 @@ public function run(): void
// === APK ===
$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');
// === MÓDULOS ===
@ -190,7 +193,7 @@ public function run(): void
// Constancias
$tagIndex, $tagCreate, $tagEdit, $tagDestroy,
//app
$apkUpload, $apkDownload,
$apkIndex, $apkEdit, $apkDestroy, $apkDownload,
);
// Encargado

View File

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

View File

@ -71,8 +71,8 @@ php artisan view:cache
echo "Generando caché de eventos..."
php artisan event:cache
echo "Creando directorio de claves Passport..."
mkdir -p storage/app/keys
echo "Creando directorios necesarios..."
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
echo "Claves de Passport no encontradas, generando..."

View File

@ -94,8 +94,11 @@
Route::post('repuve-credentials/decrypt', [SettingsController::class, 'decrypt']);
// 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::post('app/upload', [AppController::class, 'upload']);
});