up
This commit is contained in:
@@ -2,12 +2,30 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// OSSObject representa un objeto/archivo listado en el bucket
|
||||||
|
type OSSObject struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
LastModified time.Time `json:"last_modified"`
|
||||||
|
ETag string `json:"etag"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OSSListResult resultado paginado de listado de objetos
|
||||||
|
type OSSListResult struct {
|
||||||
|
Objects []OSSObject `json:"objects"`
|
||||||
|
CommonPrefixes []string `json:"prefixes"` // "carpetas" virtuales
|
||||||
|
IsTruncated bool `json:"is_truncated"`
|
||||||
|
NextMarker string `json:"next_marker"`
|
||||||
|
}
|
||||||
|
|
||||||
type OSSService struct {
|
type OSSService struct {
|
||||||
Client *oss.Client
|
Client *oss.Client
|
||||||
Bucket *oss.Bucket
|
Bucket *oss.Bucket
|
||||||
@@ -91,3 +109,77 @@ func (s *OSSService) DeleteFile(objectKey string) error {
|
|||||||
log.Printf("Archivo eliminado de OSS: %s", objectKey)
|
log.Printf("Archivo eliminado de OSS: %s", objectKey)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListObjects lista objetos del bucket con soporte de prefijo (carpeta), delimitador y paginación.
|
||||||
|
func (s *OSSService) ListObjects(prefix, marker string, maxKeys int) (*OSSListResult, error) {
|
||||||
|
if maxKeys <= 0 || maxKeys > 1000 {
|
||||||
|
maxKeys = 100
|
||||||
|
}
|
||||||
|
opts := []oss.Option{
|
||||||
|
oss.MaxKeys(maxKeys),
|
||||||
|
oss.Delimiter("/"),
|
||||||
|
}
|
||||||
|
if prefix != "" {
|
||||||
|
opts = append(opts, oss.Prefix(prefix))
|
||||||
|
}
|
||||||
|
if marker != "" {
|
||||||
|
opts = append(opts, oss.Marker(marker))
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.Bucket.ListObjects(opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error listando objetos OSS: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &OSSListResult{
|
||||||
|
IsTruncated: resp.IsTruncated,
|
||||||
|
NextMarker: resp.NextMarker,
|
||||||
|
}
|
||||||
|
for _, obj := range resp.Objects {
|
||||||
|
result.Objects = append(result.Objects, OSSObject{
|
||||||
|
Key: obj.Key,
|
||||||
|
Size: obj.Size,
|
||||||
|
LastModified: obj.LastModified,
|
||||||
|
ETag: obj.ETag,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, cp := range resp.CommonPrefixes {
|
||||||
|
result.CommonPrefixes = append(result.CommonPrefixes, cp)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignedURL genera una URL firmada temporal para descargar/previsualizar un objeto.
|
||||||
|
func (s *OSSService) SignedURL(objectKey string, expireSeconds int) (string, error) {
|
||||||
|
if expireSeconds <= 0 {
|
||||||
|
expireSeconds = 3600
|
||||||
|
}
|
||||||
|
url, err := s.Bucket.SignURL(objectKey, oss.HTTPGet, int64(expireSeconds))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error generando URL firmada: %w", err)
|
||||||
|
}
|
||||||
|
return url, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadFromReader sube un archivo desde un io.Reader con el content-type dado.
|
||||||
|
func (s *OSSService) UploadFromReader(objectKey, contentType string, r io.Reader) error {
|
||||||
|
var opts []oss.Option
|
||||||
|
if contentType != "" {
|
||||||
|
opts = append(opts, oss.ContentType(contentType))
|
||||||
|
}
|
||||||
|
err := s.Bucket.PutObject(objectKey, r, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error subiendo objeto '%s': %w", objectKey, err)
|
||||||
|
}
|
||||||
|
log.Printf("Objeto subido a OSS: %s", objectKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteObjects elimina múltiples objetos en una sola llamada (batch).
|
||||||
|
func (s *OSSService) DeleteObjects(keys []string) error {
|
||||||
|
_, err := s.Bucket.DeleteObjects(keys)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error eliminando objetos en batch: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
+287
-42
@@ -1,20 +1,20 @@
|
|||||||
<!-- Vista: Alibaba Cloud OSS — Gestión de configuraciones -->
|
<!-- Vista: Alibaba Cloud OSS — Gestión de configuraciones + Explorador -->
|
||||||
<div x-data="ossApiApp()" x-init="init()" @keydown.escape.window="closeModal()" class="bg-white rounded-lg shadow">
|
<div x-data="ossApiApp()" x-init="init()" @keydown.escape.window="closeModal()" class="bg-white rounded-lg shadow">
|
||||||
|
|
||||||
<!-- Overlay de carga -->
|
<!-- Overlay de carga -->
|
||||||
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
|
<div x-show="loading || browserLoading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
|
||||||
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
|
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container mx-auto p-6 w-full">
|
<div class="container mx-auto p-6 w-full">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold">Alibaba Cloud OSS</h1>
|
<h1 class="text-2xl font-bold">Alibaba Cloud OSS</h1>
|
||||||
<p class="text-xs text-slate-500 mt-0.5">Gestiona las configuraciones de acceso a Alibaba Cloud Object Storage Service (OSS). La configuración activa es usada por todos los servicios de almacenamiento.</p>
|
<p class="text-xs text-slate-500 mt-0.5">Gestiona configuraciones de acceso y explora archivos del bucket activo.</p>
|
||||||
</div>
|
</div>
|
||||||
<button @click="openAdd()"
|
<button x-show="activeTab === 'config'" @click="openAdd()"
|
||||||
class="flex items-center gap-2 text-white text-sm font-medium px-4 py-2 rounded-lg"
|
class="flex items-center gap-2 text-white text-sm font-medium px-4 py-2 rounded-lg"
|
||||||
style="background-color:#8eb02f"
|
style="background-color:#8eb02f"
|
||||||
onmouseover="this.style.backgroundColor='#6d8c24'"
|
onmouseover="this.style.backgroundColor='#6d8c24'"
|
||||||
@@ -24,13 +24,40 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Nueva configuración
|
Nueva configuración
|
||||||
</button>
|
</button>
|
||||||
|
<label x-show="activeTab === 'browser'" class="flex items-center gap-2 text-white text-sm font-medium px-4 py-2 rounded-lg cursor-pointer"
|
||||||
|
style="background-color:#8eb02f"
|
||||||
|
onmouseover="this.style.backgroundColor='#6d8c24'"
|
||||||
|
onmouseout="this.style.backgroundColor='#8eb02f'">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
|
||||||
|
</svg>
|
||||||
|
Subir archivo
|
||||||
|
<input type="file" class="hidden" @change="uploadFile($event)" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="border-b border-gray-200 mb-5">
|
||||||
|
<nav class="-mb-px flex gap-5 text-sm">
|
||||||
|
<button @click="activeTab = 'config'"
|
||||||
|
:class="activeTab === 'config' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-500 hover:text-gray-700'"
|
||||||
|
class="pb-2 transition-colors">Configuraciones</button>
|
||||||
|
<button @click="switchToBrowser()"
|
||||||
|
:class="activeTab === 'browser' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-500 hover:text-gray-700'"
|
||||||
|
class="pb-2 transition-colors flex items-center gap-1.5">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"/>
|
||||||
|
</svg>
|
||||||
|
Explorador de archivos
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alerta de error -->
|
<!-- Alerta de error -->
|
||||||
<div x-show="errorMsg" class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700" x-text="errorMsg"></div>
|
<div x-show="errorMsg" class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700" x-text="errorMsg"></div>
|
||||||
|
|
||||||
<!-- Barra de búsqueda -->
|
<!-- Barra de búsqueda (solo tab config) -->
|
||||||
<div class="flex flex-col sm:flex-row gap-3 mb-5">
|
<div x-show="activeTab === 'config'" class="flex flex-col sm:flex-row gap-3 mb-5">
|
||||||
<input x-model="search" @keyup.enter="load()" type="text" placeholder="Buscar por nombre..."
|
<input x-model="search" @keyup.enter="load()" type="text" placeholder="Buscar por nombre..."
|
||||||
class="border border-gray-300 rounded-lg px-3 py-2 text-sm flex-1 focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
class="border border-gray-300 rounded-lg px-3 py-2 text-sm flex-1 focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
|
||||||
<button @click="load()" :disabled="loading"
|
<button @click="load()" :disabled="loading"
|
||||||
@@ -42,8 +69,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabla -->
|
<!-- Tabla (solo tab config) -->
|
||||||
<div class="overflow-x-auto">
|
<div x-show="activeTab === 'config'" class="overflow-x-auto">
|
||||||
<table class="table-auto w-full text-sm">
|
<table class="table-auto w-full text-sm">
|
||||||
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
|
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -97,7 +124,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Paginación -->
|
<!-- Paginación -->
|
||||||
<div class="flex items-center justify-between mt-4 text-sm text-gray-500" x-show="totalPages > 1">
|
<div class="flex items-center justify-between mt-4 text-sm text-gray-500" x-show="activeTab === 'config' && totalPages > 1">
|
||||||
<span x-text="`Página ${page} de ${totalPages} — ${total} registro(s)`"></span>
|
<span x-text="`Página ${page} de ${totalPages} — ${total} registro(s)`"></span>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button @click="prevPage()" :disabled="page <= 1"
|
<button @click="prevPage()" :disabled="page <= 1"
|
||||||
@@ -106,6 +133,96 @@
|
|||||||
class="px-3 py-1 border rounded text-xs disabled:opacity-40 hover:bg-gray-50">Siguiente</button>
|
class="px-3 py-1 border rounded text-xs disabled:opacity-40 hover:bg-gray-50">Siguiente</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── TAB: Explorador de archivos ──────────────────────────────────── -->
|
||||||
|
<div x-show="activeTab === 'browser'">
|
||||||
|
|
||||||
|
<!-- Breadcrumb / ruta actual -->
|
||||||
|
<div class="flex items-center gap-1 text-sm mb-3 flex-wrap">
|
||||||
|
<button @click="navigateTo('')" class="text-[#8eb02f] hover:underline font-medium">Raíz</button>
|
||||||
|
<template x-for="(seg, i) in breadcrumbs()" :key="i">
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="text-gray-400">/</span>
|
||||||
|
<button @click="navigateTo(seg.prefix)" class="text-[#8eb02f] hover:underline" x-text="seg.label"></button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Barra de herramientas explorador -->
|
||||||
|
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||||
|
<span class="text-xs text-gray-400" x-text="browserPrefix ? `Carpeta: ${browserPrefix}` : 'Bucket raíz'"></span>
|
||||||
|
<button @click="browserLoad()" :disabled="browserLoading"
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 border border-gray-300 rounded-lg text-xs hover:bg-gray-50 transition disabled:opacity-40">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" :class="browserLoading && 'animate-spin'" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"/>
|
||||||
|
</svg>
|
||||||
|
Recargar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mensaje error explorador -->
|
||||||
|
<div x-show="browserError" class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700" x-text="browserError"></div>
|
||||||
|
|
||||||
|
<!-- Lista de objetos -->
|
||||||
|
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<!-- Carpetas virtuales -->
|
||||||
|
<template x-for="prefix in browserResult.prefixes || []" :key="prefix">
|
||||||
|
<div class="flex items-center gap-3 px-4 py-2.5 border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||||
|
@click="navigateTo(prefix)">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-yellow-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M19.5 21a3 3 0 003-3v-4.5a3 3 0 00-3-3h-15a3 3 0 00-3 3V18a3 3 0 003 3h15zM1.5 10.146V6a3 3 0 013-3h5.379a2.25 2.25 0 011.59.659l2.122 2.121c.14.141.331.22.53.22H19.5a3 3 0 013 3v1.146A4.483 4.483 0 0019.5 9h-15a4.483 4.483 0 00-3 1.146z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium text-gray-700" x-text="folderName(prefix)"></span>
|
||||||
|
<span class="ml-auto text-xs text-gray-400">Carpeta →</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Archivos -->
|
||||||
|
<template x-for="obj in browserResult.objects || []" :key="obj.key">
|
||||||
|
<div class="flex items-center gap-3 px-4 py-2.5 border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
||||||
|
<span x-html="fileIcon(obj.key)" class="flex-shrink-0"></span>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm text-gray-800 truncate" x-text="fileName(obj.key)"></p>
|
||||||
|
<p class="text-xs text-gray-400" x-text="obj.key"></p>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-500 flex-shrink-0" x-text="formatSize(obj.size)"></span>
|
||||||
|
<span class="text-xs text-gray-400 flex-shrink-0 hidden sm:block" x-text="formatDate(obj.last_modified)"></span>
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<button @click="previewOrDownload(obj.key)" title="Ver / Descargar"
|
||||||
|
class="text-[#8eb02f] hover:text-[#6d8c24] transition-colors">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button @click="confirmBrowserDelete(obj.key)" title="Eliminar"
|
||||||
|
class="text-red-400 hover:text-red-600 transition-colors">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Vacío -->
|
||||||
|
<div x-show="!browserLoading && (browserResult.objects || []).length === 0 && (browserResult.prefixes || []).length === 0"
|
||||||
|
class="py-10 text-center text-gray-400 text-sm">
|
||||||
|
Carpeta vacía
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Paginación explorador -->
|
||||||
|
<div class="flex items-center justify-between mt-3 text-xs text-gray-500" x-show="browserResult.is_truncated || browserMarker">
|
||||||
|
<span x-show="browserMarker" class="text-gray-400">Mostrando desde el marcador actual</span>
|
||||||
|
<div class="flex gap-2 ml-auto">
|
||||||
|
<button @click="browserPrev()" :disabled="markerHistory.length === 0"
|
||||||
|
class="px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-50">← Anterior</button>
|
||||||
|
<button @click="browserNext()" :disabled="!browserResult.is_truncated"
|
||||||
|
class="px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-50">Siguiente →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── Modal crear / editar ─────────────────────────────────────────────── -->
|
<!-- ─── Modal crear / editar ─────────────────────────────────────────────── -->
|
||||||
@@ -221,6 +338,10 @@
|
|||||||
<script>
|
<script>
|
||||||
function ossApiApp() {
|
function ossApiApp() {
|
||||||
return {
|
return {
|
||||||
|
// ── Tab activo ────────────────────────────────────────────
|
||||||
|
activeTab: 'config',
|
||||||
|
|
||||||
|
// ── CRUD Configuraciones ──────────────────────────────────
|
||||||
items: [],
|
items: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
@@ -228,28 +349,30 @@ function ossApiApp() {
|
|||||||
search: '',
|
search: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
errorMsg: '',
|
errorMsg: '',
|
||||||
|
|
||||||
showModal: false,
|
showModal: false,
|
||||||
editItem: null,
|
editItem: null,
|
||||||
saving: false,
|
saving: false,
|
||||||
modalError: '',
|
modalError: '',
|
||||||
deleteId: null,
|
deleteId: null,
|
||||||
|
|
||||||
form: {
|
form: {
|
||||||
name: '',
|
name: '', endpoint: '', access_key_id: '', access_key_secret: '',
|
||||||
endpoint: '',
|
bucket_name: '', region: '', is_active: true, notes: '',
|
||||||
access_key_id: '',
|
|
||||||
access_key_secret: '',
|
|
||||||
bucket_name: '',
|
|
||||||
region: '',
|
|
||||||
is_active: true,
|
|
||||||
notes: '',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Explorador de archivos ────────────────────────────────
|
||||||
|
browserLoading: false,
|
||||||
|
browserError: '',
|
||||||
|
browserPrefix: '',
|
||||||
|
browserMarker: '',
|
||||||
|
markerHistory: [],
|
||||||
|
browserResult: { objects: [], prefixes: [], is_truncated: false, next_marker: '' },
|
||||||
|
browserDeleteKey: null,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
await this.load();
|
await this.load();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ─ Configs ─────────────────────────────────────────────────
|
||||||
async load() {
|
async load() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
@@ -277,16 +400,7 @@ function ossApiApp() {
|
|||||||
|
|
||||||
openEdit(item) {
|
openEdit(item) {
|
||||||
this.editItem = item;
|
this.editItem = item;
|
||||||
this.form = {
|
this.form = { name: item.name || '', endpoint: item.endpoint || '', access_key_id: '', access_key_secret: '', bucket_name: item.bucket_name || '', region: item.region || '', is_active: item.is_active, notes: item.notes || '' };
|
||||||
name: item.name || '',
|
|
||||||
endpoint: item.endpoint || '',
|
|
||||||
access_key_id: '',
|
|
||||||
access_key_secret: '',
|
|
||||||
bucket_name: item.bucket_name || '',
|
|
||||||
region: item.region || '',
|
|
||||||
is_active: item.is_active,
|
|
||||||
notes: item.notes || '',
|
|
||||||
};
|
|
||||||
this.modalError = '';
|
this.modalError = '';
|
||||||
this.showModal = true;
|
this.showModal = true;
|
||||||
},
|
},
|
||||||
@@ -303,11 +417,7 @@ function ossApiApp() {
|
|||||||
try {
|
try {
|
||||||
const url = this.editItem ? `/app/oss-api/${this.editItem.ID}` : '/app/oss-api';
|
const url = this.editItem ? `/app/oss-api/${this.editItem.ID}` : '/app/oss-api';
|
||||||
const method = this.editItem ? 'PUT' : 'POST';
|
const method = this.editItem ? 'PUT' : 'POST';
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.form) });
|
||||||
method,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(this.form),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || 'Error al guardar');
|
if (!res.ok) throw new Error(data.error || 'Error al guardar');
|
||||||
this.closeModal();
|
this.closeModal();
|
||||||
@@ -319,9 +429,7 @@ function ossApiApp() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
confirmDelete(id) {
|
confirmDelete(id) { this.deleteId = id; },
|
||||||
this.deleteId = id;
|
|
||||||
},
|
|
||||||
|
|
||||||
async doDelete() {
|
async doDelete() {
|
||||||
if (!this.deleteId) return;
|
if (!this.deleteId) return;
|
||||||
@@ -340,12 +448,149 @@ function ossApiApp() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
prevPage() {
|
prevPage() { if (this.page > 1) { this.page--; this.load(); } },
|
||||||
if (this.page > 1) { this.page--; this.load(); }
|
nextPage() { if (this.page < this.totalPages) { this.page++; this.load(); } },
|
||||||
|
|
||||||
|
// ─ Explorador ──────────────────────────────────────────────
|
||||||
|
switchToBrowser() {
|
||||||
|
this.activeTab = 'browser';
|
||||||
|
if ((this.browserResult.objects || []).length === 0 && (this.browserResult.prefixes || []).length === 0) {
|
||||||
|
this.browserLoad();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
nextPage() {
|
async browserLoad() {
|
||||||
if (this.page < this.totalPages) { this.page++; this.load(); }
|
this.browserLoading = true;
|
||||||
|
this.browserError = '';
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ prefix: this.browserPrefix, marker: this.browserMarker, max_keys: 100 });
|
||||||
|
const res = await fetch(`/app/oss-api/browser?${params}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Error al listar objetos');
|
||||||
|
this.browserResult = { objects: data.objects || [], prefixes: data.prefixes || [], is_truncated: data.is_truncated || false, next_marker: data.next_marker || '' };
|
||||||
|
} catch (e) {
|
||||||
|
this.browserError = e.message;
|
||||||
|
} finally {
|
||||||
|
this.browserLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
navigateTo(prefix) {
|
||||||
|
this.markerHistory = [];
|
||||||
|
this.browserMarker = '';
|
||||||
|
this.browserPrefix = prefix;
|
||||||
|
this.browserLoad();
|
||||||
|
},
|
||||||
|
|
||||||
|
browserNext() {
|
||||||
|
if (!this.browserResult.is_truncated) return;
|
||||||
|
this.markerHistory.push(this.browserMarker);
|
||||||
|
this.browserMarker = this.browserResult.next_marker;
|
||||||
|
this.browserLoad();
|
||||||
|
},
|
||||||
|
|
||||||
|
browserPrev() {
|
||||||
|
if (this.markerHistory.length === 0) return;
|
||||||
|
this.browserMarker = this.markerHistory.pop();
|
||||||
|
this.browserLoad();
|
||||||
|
},
|
||||||
|
|
||||||
|
breadcrumbs() {
|
||||||
|
if (!this.browserPrefix) return [];
|
||||||
|
const parts = this.browserPrefix.replace(/\/$/, '').split('/').filter(Boolean);
|
||||||
|
return parts.map((label, i) => ({ label, prefix: parts.slice(0, i + 1).join('/') + '/' }));
|
||||||
|
},
|
||||||
|
|
||||||
|
async previewOrDownload(key) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/app/oss-api/browser/url?key=${encodeURIComponent(key)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Error generando URL');
|
||||||
|
window.open(data.url, '_blank');
|
||||||
|
} catch (e) {
|
||||||
|
this.browserError = e.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
confirmBrowserDelete(key) { this.browserDeleteKey = key; this.deleteId = 'browser:' + key; },
|
||||||
|
|
||||||
|
async doDelete() {
|
||||||
|
if (!this.deleteId) return;
|
||||||
|
this.saving = true;
|
||||||
|
try {
|
||||||
|
if (String(this.deleteId).startsWith('browser:')) {
|
||||||
|
const key = this.browserDeleteKey;
|
||||||
|
const res = await fetch('/app/oss-api/browser/object', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Error al eliminar');
|
||||||
|
this.deleteId = null;
|
||||||
|
this.browserDeleteKey = null;
|
||||||
|
await this.browserLoad();
|
||||||
|
} else {
|
||||||
|
const res = await fetch(`/app/oss-api/${this.deleteId}`, { method: 'DELETE' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Error al eliminar');
|
||||||
|
this.deleteId = null;
|
||||||
|
await this.load();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.errorMsg = e.message;
|
||||||
|
this.deleteId = null;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadFile(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
this.browserLoading = true;
|
||||||
|
this.browserError = '';
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
fd.append('prefix', this.browserPrefix);
|
||||||
|
const res = await fetch('/app/oss-api/browser/upload', { method: 'POST', body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Error al subir archivo');
|
||||||
|
await this.browserLoad();
|
||||||
|
} catch (e) {
|
||||||
|
this.browserError = e.message;
|
||||||
|
} finally {
|
||||||
|
this.browserLoading = false;
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─ Helpers de UI ───────────────────────────────────────────
|
||||||
|
folderName(prefix) { return prefix.replace(/\/$/, '').split('/').pop() || prefix; },
|
||||||
|
fileName(key) { return key.split('/').pop() || key; },
|
||||||
|
|
||||||
|
formatSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024, sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return (bytes / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1) + ' ' + sizes[i];
|
||||||
|
},
|
||||||
|
|
||||||
|
formatDate(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleDateString('es', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
},
|
||||||
|
|
||||||
|
fileIcon(key) {
|
||||||
|
const ext = (key.split('.').pop() || '').toLowerCase();
|
||||||
|
const img = ['jpg','jpeg','png','gif','webp','svg','bmp','ico'];
|
||||||
|
const vid = ['mp4','avi','mov','mkv','webm'];
|
||||||
|
const doc = ['pdf','doc','docx','xls','xlsx','ppt','pptx'];
|
||||||
|
const arc = ['zip','tar','gz','rar','7z'];
|
||||||
|
const cod = ['js','ts','go','py','json','yaml','yml','html','css','xml','sh'];
|
||||||
|
if (img.includes(ext)) return `<svg class="w-5 h-5 text-pink-400" fill="currentColor" viewBox="0 0 24 24"><path d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M2.25 18.75V6.75A2.25 2.25 0 014.5 4.5h15A2.25 2.25 0 0121.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25H4.5A2.25 2.25 0 012.25 18.75zM16.5 8.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0z"/></svg>`;
|
||||||
|
if (vid.includes(ext)) return `<svg class="w-5 h-5 text-purple-400" fill="currentColor" viewBox="0 0 24 24"><path d="M3 8.25V18a2.25 2.25 0 002.25 2.25h13.5A2.25 2.25 0 0021 18V8.25M3 8.25L12 3l9 5.25M3 8.25h18"/></svg>`;
|
||||||
|
if (doc.includes(ext)) return `<svg class="w-5 h-5 text-blue-400" fill="currentColor" viewBox="0 0 24 24"><path d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>`;
|
||||||
|
if (arc.includes(ext)) return `<svg class="w-5 h-5 text-orange-400" fill="currentColor" viewBox="0 0 24 24"><path d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"/></svg>`;
|
||||||
|
if (cod.includes(ext)) return `<svg class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 24 24"><path d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z"/></svg>`;
|
||||||
|
return `<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 24 24"><path d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>`;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package controllers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
"mime"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OssApiIndex renderiza la vista del panel de gestión de Alibaba Cloud OSS.
|
// OssApiIndex renderiza la vista del panel de gestión de Alibaba Cloud OSS.
|
||||||
@@ -157,3 +160,102 @@ func DeleteOssApiConfig(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
return c.JSON(fiber.Map{"ok": true})
|
return c.JSON(fiber.Map{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Explorador de archivos OSS ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// OssBrowserList lista los objetos del bucket activo con soporte de carpetas virtuales.
|
||||||
|
// Query params: prefix (carpeta actual), marker (paginación), max_keys
|
||||||
|
func OssBrowserList(c *fiber.Ctx) error {
|
||||||
|
svc, err := services.NewOSSServiceFromDB()
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Sin configuración OSS activa: " + err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := c.Query("prefix", "")
|
||||||
|
marker := c.Query("marker", "")
|
||||||
|
maxKeys, _ := strconv.Atoi(c.Query("max_keys", "100"))
|
||||||
|
|
||||||
|
result, err := svc.ListObjects(prefix, marker, maxKeys)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OssBrowserSignedURL genera una URL firmada temporal (1 hora) para previsualizar o descargar un objeto.
|
||||||
|
// Query param: key (objeto)
|
||||||
|
func OssBrowserSignedURL(c *fiber.Ctx) error {
|
||||||
|
key := strings.TrimSpace(c.Query("key", ""))
|
||||||
|
if key == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "key es requerido"})
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := services.NewOSSServiceFromDB()
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Sin configuración OSS activa: " + err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := svc.SignedURL(key, 3600)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"url": url})
|
||||||
|
}
|
||||||
|
|
||||||
|
// OssBrowserDelete elimina un objeto del bucket activo.
|
||||||
|
// Body JSON: { "key": "ruta/al/archivo.jpg" }
|
||||||
|
func OssBrowserDelete(c *fiber.Ctx) error {
|
||||||
|
type Req struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
var req Req
|
||||||
|
if err := c.BodyParser(&req); err != nil || strings.TrimSpace(req.Key) == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "key es requerido"})
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := services.NewOSSServiceFromDB()
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Sin configuración OSS activa: " + err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.DeleteFile(req.Key); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// OssBrowserUpload sube un archivo al bucket activo.
|
||||||
|
// Form field: file (multipart), prefix (carpeta destino opcional)
|
||||||
|
func OssBrowserUpload(c *fiber.Ctx) error {
|
||||||
|
prefix := strings.TrimSpace(c.FormValue("prefix", ""))
|
||||||
|
fh, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "archivo requerido"})
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := fh.Open()
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "error abriendo archivo"})
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
objectKey := fh.Filename
|
||||||
|
if prefix != "" {
|
||||||
|
objectKey = strings.TrimRight(prefix, "/") + "/" + fh.Filename
|
||||||
|
}
|
||||||
|
|
||||||
|
ct := mime.TypeByExtension(filepath.Ext(fh.Filename))
|
||||||
|
if ct == "" {
|
||||||
|
ct = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := services.NewOSSServiceFromDB()
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Sin configuración OSS activa: " + err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.UploadFromReader(objectKey, ct, f); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "key": objectKey})
|
||||||
|
}
|
||||||
|
|||||||
@@ -277,6 +277,11 @@ func UserRoutes(app fiber.Router) {
|
|||||||
protected.Post("/oss-api", controllers.CreateOssApiConfig)
|
protected.Post("/oss-api", controllers.CreateOssApiConfig)
|
||||||
protected.Put("/oss-api/:id", controllers.UpdateOssApiConfig)
|
protected.Put("/oss-api/:id", controllers.UpdateOssApiConfig)
|
||||||
protected.Delete("/oss-api/:id", controllers.DeleteOssApiConfig)
|
protected.Delete("/oss-api/:id", controllers.DeleteOssApiConfig)
|
||||||
|
// Explorador de archivos OSS
|
||||||
|
protected.Get("/oss-api/browser", controllers.OssBrowserList)
|
||||||
|
protected.Get("/oss-api/browser/url", controllers.OssBrowserSignedURL)
|
||||||
|
protected.Delete("/oss-api/browser/object", controllers.OssBrowserDelete)
|
||||||
|
protected.Post("/oss-api/browser/upload", controllers.OssBrowserUpload)
|
||||||
|
|
||||||
// ─── VCard API (integración Admin Laravel) ────────────────────────────────
|
// ─── VCard API (integración Admin Laravel) ────────────────────────────────
|
||||||
protected.Get("/vcard-api", middlewares.MenuMiddleware, controllers.VcardApiIndex)
|
protected.Get("/vcard-api", middlewares.MenuMiddleware, controllers.VcardApiIndex)
|
||||||
|
|||||||
Reference in New Issue
Block a user