mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-03 18:47:41 +01:00
* admin: carry a file browser upload size limit The admin server keeps the total size limit of one file browser upload, 500 MB by default as the UI enforced before, and hands it to the file browser page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: reject file browser uploads over the size limit The 500 MB limit was only checked in the browser, so any client could post larger uploads and fill the temp dir with spooled parts. Cap the request body at the limit plus room for the multipart framing, and answer 413 when the files add up to more than the limit. Parse the form before reading the path field. FormValue used to parse it first with the default 32 MB memory, which made the later 1 GB ParseMultipartForm a no-op; keep 32 MB explicitly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: show and check the configured upload limit in the file browser The upload dialog reads the limit from the page instead of a hard-coded 500 MB, lists it under the file picker, and skips the check when uploads are unlimited. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: add -uploadMaxSizeMB to set the file browser upload limit weed admin -uploadMaxSizeMB and weed mini -admin.uploadMaxSizeMB set the total size limit of one file browser upload; 0 removes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: note why the upload framing allowance is enough Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: test an upload whose files only exceed the limit together Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: remove spooled upload parts when the upload handler returns Parts over 32 MB spool to temp files. The router passes the handler a copy of the request, so net/http never sees the parsed form and never removes those files. Every large upload, and every one rejected for size after parsing, left them on disk. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
809 lines
28 KiB
Templ
809 lines
28 KiB
Templ
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
|
|
)
|
|
|
|
script changePageSize(path string, lastFileName string, pageSizeSelectId string) {
|
|
const pageSizeSelect = document.getElementById(pageSizeSelectId)
|
|
if (pageSizeSelect == null) {
|
|
return
|
|
}
|
|
window.location.href = (window.__BASE_PATH__ || '') + '/files?path=' + encodeURIComponent(path) + '&lastFileName=' + encodeURIComponent(lastFileName) + '&limit=' + pageSizeSelect.value
|
|
}
|
|
|
|
templ FileBrowser(data dash.FileBrowserData) {
|
|
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
|
<h1 class="h2">
|
|
if data.IsTableBucketPath && data.TableBucketName != "" {
|
|
<i class="bi bi-table me-2"></i>Table Bucket: {data.TableBucketName}
|
|
} else if data.IsBucketPath && data.BucketName != "" {
|
|
<i class="bi bi-box me-2"></i>S3 Bucket: {data.BucketName}
|
|
} else {
|
|
<i class="bi bi-folder2-open me-2"></i>File Browser
|
|
}
|
|
</h1>
|
|
<div class="btn-toolbar mb-2 mb-md-0">
|
|
<div class="btn-group me-2">
|
|
if data.IsTableBucketPath && data.TableBucketName != "" {
|
|
<a href={ dash.PUrl(ctx, "/object-store/s3tables/buckets") } class="btn btn-sm btn-outline-secondary">
|
|
<i class="bi bi-arrow-left me-1"></i>Back to Table Buckets
|
|
</a>
|
|
} else if data.IsBucketPath && data.BucketName != "" {
|
|
<a href={ dash.PUrl(ctx, "/object-store/buckets") } class="btn btn-sm btn-outline-secondary">
|
|
<i class="bi bi-arrow-left me-1"></i>Back to Buckets
|
|
</a>
|
|
}
|
|
<button type="button" class="btn btn-sm btn-outline-primary" onclick="createFolder()">
|
|
<i class="bi bi-folder-plus me-1"></i>New Folder
|
|
</button>
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="uploadFile()">
|
|
<i class="bi bi-upload me-1"></i>Upload
|
|
</button>
|
|
<button type="button" class="btn btn-sm btn-outline-danger" id="deleteSelectedBtn" onclick="confirmDeleteSelected()" style="display: none;">
|
|
<i class="bi bi-trash me-1"></i>Delete Selected
|
|
</button>
|
|
<button type="button" class="btn btn-sm btn-outline-info" onclick="exportFileList()">
|
|
<i class="bi bi-download me-1"></i>Export
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Breadcrumb Navigation -->
|
|
<nav aria-label="breadcrumb" class="mb-3">
|
|
<ol class="breadcrumb">
|
|
for i, crumb := range data.Breadcrumbs {
|
|
if i == len(data.Breadcrumbs)-1 {
|
|
<li class="breadcrumb-item active" aria-current="page">
|
|
<a href={ dash.PUrl(ctx, fileBrowserPathURL(crumb.Path)) } class="text-decoration-none">
|
|
{ crumb.Name }
|
|
</a>
|
|
</li>
|
|
} else {
|
|
<li class="breadcrumb-item">
|
|
<a href={ dash.PUrl(ctx, fileBrowserPathURL(crumb.Path)) } class="text-decoration-none">
|
|
if crumb.Name == "Root" {
|
|
<i class="bi bi-house me-1"></i>
|
|
}
|
|
{ crumb.Name }
|
|
</a>
|
|
</li>
|
|
}
|
|
}
|
|
</ol>
|
|
</nav>
|
|
|
|
<!-- File Listing -->
|
|
<div class="card shadow mb-4">
|
|
<div class="card-header py-3 d-flex justify-content-between align-items-center flex-wrap">
|
|
<h6 class="m-0 font-weight-bold text-primary">
|
|
<i class="bi bi-folder2-open me-2"></i>
|
|
if data.CurrentPath == "/" {
|
|
<a href={ dash.PUrl(ctx, fileBrowserPathURL("/")) } class="text-decoration-none text-primary">Root Directory</a>
|
|
} else if data.CurrentPath == "/buckets" {
|
|
<a href={ dash.PUrl(ctx, fileBrowserPathURL("/buckets")) } class="text-decoration-none text-primary">Object Store Buckets Directory</a>
|
|
<a href={ dash.PUrl(ctx, "/object-store/buckets") } class="btn btn-sm btn-outline-primary ms-2">
|
|
<i class="bi bi-box me-1"></i>Manage Buckets
|
|
</a>
|
|
} else {
|
|
<a href={ dash.PUrl(ctx, fileBrowserPathURL(data.CurrentPath)) } class="text-decoration-none text-primary">{ filepath.Base(data.CurrentPath) }</a>
|
|
}
|
|
</h6>
|
|
|
|
<!-- Compact pagination controls -->
|
|
<div class="d-flex align-items-center gap-2 ms-auto">
|
|
<div class="d-flex align-items-center">
|
|
<label class="me-1 mb-0 small text-muted">Show:</label>
|
|
<select id="file-browser-page-size-top" class="form-select form-select-sm" style="width: 70px; font-size: 0.875rem;" onchange={ changePageSize(data.CurrentPath, data.CurrentLastFileName, "file-browser-page-size-top") }>
|
|
<option value="20" selected?={ data.PageSize == 20 }>20</option>
|
|
<option value="50" selected?={ data.PageSize == 50 }>50</option>
|
|
<option value="100" selected?={ data.PageSize == 100 }>100</option>
|
|
<option value="200" selected?={ data.PageSize == 200 }>200</option>
|
|
</select>
|
|
</div>
|
|
<div class="btn-group btn-group-sm" role="group">
|
|
if data.HasNextPage {
|
|
<a href={ dash.PUrl(ctx, fileBrowserPageURL(data.CurrentPath, data.LastFileName, data.PageSize)) } class="btn btn-outline-primary" title="Next page">
|
|
Next <i class="bi bi-chevron-right"></i>
|
|
</a>
|
|
} else {
|
|
<button class="btn btn-outline-secondary" disabled title="Next page">
|
|
Next <i class="bi bi-chevron-right"></i>
|
|
</button>
|
|
}
|
|
if data.ParentPath != data.CurrentPath {
|
|
<a href={ dash.PUrl(ctx, fileBrowserPathURL(data.ParentPath)) } class="btn btn-outline-secondary" title="Go up one directory">
|
|
<i class="bi bi-arrow-up"></i> Up
|
|
</a>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
if len(data.Entries) > 0 {
|
|
<div class="table-responsive">
|
|
<table class="table table-hover" id="fileTable">
|
|
<thead>
|
|
<tr>
|
|
<th width="40px">
|
|
<input type="checkbox" id="selectAll" onchange="toggleSelectAll()">
|
|
</th>
|
|
<th>Name</th>
|
|
<th>Size</th>
|
|
<th>Type</th>
|
|
<th>Modified</th>
|
|
<th>Permissions</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
for _, entry := range data.Entries {
|
|
<tr>
|
|
<td>
|
|
<input type="checkbox" class="file-checkbox" value={ entry.FullPath }>
|
|
</td>
|
|
<td>
|
|
<div class="d-flex align-items-center">
|
|
if entry.IsDirectory {
|
|
<i class="bi bi-folder text-warning me-2"></i>
|
|
<a href={ dash.PUrl(ctx, fileBrowserPathURL(entry.FullPath)) } class="text-decoration-none">
|
|
{ entry.Name }
|
|
</a>
|
|
} else {
|
|
<i class={ fmt.Sprintf("bi %s text-muted me-2", getFileIcon(entry.Mime)) }></i>
|
|
<span>{ entry.Name }</span>
|
|
}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
if entry.IsDirectory {
|
|
<span class="text-muted">—</span>
|
|
} else {
|
|
{ formatBytes(entry.Size) }
|
|
}
|
|
</td>
|
|
<td>
|
|
<span class="badge bg-light text-dark">
|
|
if entry.IsDirectory {
|
|
Directory
|
|
} else {
|
|
{ getMimeDisplayName(entry.Mime) }
|
|
}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
if !entry.ModTime.IsZero() {
|
|
{ entry.ModTime.Format("2006-01-02 15:04") }
|
|
} else {
|
|
<span class="text-muted">—</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
<code class="small permissions-display" data-mode={ entry.Mode } data-is-directory={ fmt.Sprintf("%t", entry.IsDirectory) }>{ entry.Mode }</code>
|
|
</td>
|
|
<td>
|
|
<div class="btn-group btn-group-sm" role="group">
|
|
if !entry.IsDirectory {
|
|
<button type="button" class="btn btn-outline-primary btn-sm" title="Download" data-action="download" data-path={ entry.FullPath }>
|
|
<i class="bi bi-download"></i>
|
|
</button>
|
|
<button type="button" class="btn btn-outline-info btn-sm" title="View" data-action="view" data-path={ entry.FullPath }>
|
|
<i class="bi bi-eye"></i>
|
|
</button>
|
|
if data.S3Endpoint != "" {
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" title="Copy S3 URL" data-action="copy-url" data-url={ dash.S3ObjectURL(data.S3Endpoint, entry.FullPath) }>
|
|
<i class="bi bi-link"></i>
|
|
</button>
|
|
}
|
|
}
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" title="Properties" data-action="properties" data-path={ entry.FullPath }>
|
|
<i class="bi bi-info-circle"></i>
|
|
</button>
|
|
<button type="button" class="btn btn-outline-success btn-sm" title="Export metadata" data-action="export-metadata" data-path={ entry.FullPath }>
|
|
<i class="bi bi-file-earmark-arrow-up"></i>
|
|
</button>
|
|
<button type="button" class="btn btn-outline-danger btn-sm" title="Delete" data-action="delete" data-path={ entry.FullPath }>
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
} else {
|
|
<div class="text-center py-5">
|
|
<i class="bi bi-folder2-open icon-3x text-muted mb-3"></i>
|
|
<h5 class="text-muted">Empty Directory</h5>
|
|
<p class="text-muted">This directory contains no files or subdirectories.</p>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Last Updated -->
|
|
|
|
<!-- Pagination Controls (Bottom) -->
|
|
<div class="row mb-3">
|
|
<div class="col-md-6">
|
|
<div class="d-flex align-items-center">
|
|
<label class="me-2 mb-0">Show:</label>
|
|
<select id="file-browser-page-size-bottom" class="form-select form-select-sm" style="width: auto;" onchange={ changePageSize(data.CurrentPath, data.CurrentLastFileName, "file-browser-page-size-bottom") }>
|
|
<option value="20" selected?={ data.PageSize == 20 }>20</option>
|
|
<option value="50" selected?={ data.PageSize == 50 }>50</option>
|
|
<option value="100" selected?={ data.PageSize == 100 }>100</option>
|
|
<option value="200" selected?={ data.PageSize == 200 }>200</option>
|
|
</select>
|
|
<span class="ms-2 text-muted">entries per page</span>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6 text-end">
|
|
<div class="btn-group btn-group-sm" role="group">
|
|
|
|
if data.HasNextPage {
|
|
<a href={ dash.PUrl(ctx, fileBrowserPageURL(data.CurrentPath, data.LastFileName, data.PageSize)) } class="btn btn-outline-primary">
|
|
Next <i class="bi bi-chevron-right ms-1"></i>
|
|
</a>
|
|
} else {
|
|
<button class="btn btn-outline-secondary" disabled>
|
|
Next <i class="bi bi-chevron-right ms-1"></i>
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<small class="text-muted">
|
|
<i class="bi bi-clock me-1"></i>
|
|
Last updated: { data.LastUpdated.Format("2006-01-02 15:04:05") }
|
|
</small>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create Folder Modal -->
|
|
<div class="modal fade" id="createFolderModal" tabindex="-1" aria-labelledby="createFolderModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="createFolderModalLabel">
|
|
<i class="bi bi-folder-plus me-2"></i>Create New Folder
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="createFolderForm">
|
|
<div class="mb-3">
|
|
<label for="folderName" class="form-label">Folder Name</label>
|
|
<input type="text" class="form-control" id="folderName" name="folderName" required
|
|
placeholder="Enter folder name" maxlength="255">
|
|
<div class="form-text">
|
|
Folder names cannot contain / or \ characters.
|
|
</div>
|
|
</div>
|
|
<input type="hidden" id="currentPath" name="currentPath" value={ data.CurrentPath }>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" onclick="submitCreateFolder()">
|
|
<i class="bi bi-folder-plus me-1"></i>Create Folder
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Upload File Modal -->
|
|
<div class="modal fade" id="uploadFileModal" tabindex="-1" aria-labelledby="uploadFileModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="uploadFileModalLabel">
|
|
<i class="bi bi-upload me-2"></i>Upload Files
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="uploadFileForm" enctype="multipart/form-data" data-max-size={ fmt.Sprint(data.UploadMaxSize) }>
|
|
<div class="mb-3">
|
|
<label for="fileInput" class="form-label">Select Files</label>
|
|
<input type="file" class="form-control" id="fileInput" name="files" multiple required>
|
|
<div class="form-text">
|
|
Choose one or more files to upload to the current directory. You can select multiple files by holding Ctrl (Cmd on Mac) while clicking.
|
|
if data.UploadMaxSize > 0 {
|
|
Total upload size limit: { formatBytes(data.UploadMaxSize) }.
|
|
}
|
|
</div>
|
|
</div>
|
|
<input type="hidden" id="uploadPath" name="path" value={ data.CurrentPath }>
|
|
|
|
<!-- File List Preview -->
|
|
<div id="fileListPreview" class="mb-3" style="display: none;">
|
|
<label class="form-label">Selected Files:</label>
|
|
<div id="selectedFilesList" class="border rounded p-2 bg-light">
|
|
<!-- Files will be listed here -->
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Upload Progress -->
|
|
<div class="mb-3" id="uploadProgress" style="display: none;">
|
|
<label class="form-label">Upload Progress:</label>
|
|
<div class="progress mb-2">
|
|
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0%</div>
|
|
</div>
|
|
<div id="uploadStatus" class="small text-muted">
|
|
Preparing upload...
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" onclick="submitUploadFile()">
|
|
<i class="bi bi-upload me-1"></i>Upload Files
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- JavaScript for file browser functionality -->
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Format permissions in the main table
|
|
document.querySelectorAll('.permissions-display').forEach(element => {
|
|
const mode = element.getAttribute('data-mode');
|
|
const isDirectory = element.getAttribute('data-is-directory') === 'true';
|
|
if (mode) {
|
|
element.textContent = formatPermissions(mode, isDirectory);
|
|
}
|
|
});
|
|
|
|
// Handle file browser action buttons (download, view, properties, export-metadata, delete)
|
|
document.addEventListener('click', function(e) {
|
|
const button = e.target.closest('[data-action]');
|
|
if (!button) return;
|
|
|
|
const action = button.getAttribute('data-action');
|
|
if (action === 'copy-url') {
|
|
const url = button.getAttribute('data-url');
|
|
if (url) adminCopyToClipboard(url);
|
|
return;
|
|
}
|
|
const path = button.getAttribute('data-path');
|
|
|
|
if (!path) return;
|
|
|
|
switch(action) {
|
|
case 'download':
|
|
downloadFile(path);
|
|
break;
|
|
case 'view':
|
|
viewFile(path);
|
|
break;
|
|
case 'properties':
|
|
showFileProperties(path);
|
|
break;
|
|
case 'export-metadata':
|
|
exportMetadata(path);
|
|
break;
|
|
case 'delete':
|
|
const fileName = path.split('/').pop();
|
|
showDeleteConfirm(fileName, function() {
|
|
deleteFile(path);
|
|
}, `Are you sure you want to delete "${fileName}"? This action cannot be undone.`);
|
|
break;
|
|
}
|
|
});
|
|
|
|
// Initialize file manager event handlers from admin.js
|
|
if (typeof setupFileManagerEventHandlers === 'function') {
|
|
setupFileManagerEventHandlers();
|
|
}
|
|
});
|
|
|
|
// File browser specific functions
|
|
function downloadFile(path) {
|
|
// Open download URL in new tab
|
|
window.open((window.__BASE_PATH__ || '') + '/api/files/download?path=' + encodeURIComponent(path), '_blank');
|
|
}
|
|
|
|
function viewFile(path) {
|
|
// Open file viewer in new tab
|
|
window.open((window.__BASE_PATH__ || '') + '/api/files/view?path=' + encodeURIComponent(path), '_blank');
|
|
}
|
|
|
|
function exportMetadata(path) {
|
|
// Download metadata as an fs.meta.load-compatible .meta.gz
|
|
window.open((window.__BASE_PATH__ || '') + '/api/files/metadata?path=' + encodeURIComponent(path), '_blank');
|
|
}
|
|
|
|
function showFileProperties(path) {
|
|
// Fetch file properties and show in modal
|
|
fetch((window.__BASE_PATH__ || '') + '/api/files/properties?path=' + encodeURIComponent(path))
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
showAlert('Error loading file properties: ' + data.error, 'error');
|
|
} else {
|
|
displayFileProperties(data);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error fetching file properties:', error);
|
|
showAlert('Error loading file properties: ' + error.message, 'error');
|
|
});
|
|
}
|
|
|
|
function displayFileProperties(data) {
|
|
// Create a comprehensive modal for file properties
|
|
const modalHtml = '<div class="modal fade" id="filePropertiesModal" tabindex="-1">' +
|
|
'<div class="modal-dialog modal-lg">' +
|
|
'<div class="modal-content">' +
|
|
'<div class="modal-header">' +
|
|
'<h5 class="modal-title"><i class="bi bi-info-circle me-2"></i>Properties: ' + (data.name || 'Unknown') + '</h5>' +
|
|
'<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' +
|
|
'</div>' +
|
|
'<div class="modal-body">' +
|
|
createFilePropertiesContent(data) +
|
|
'</div>' +
|
|
'<div class="modal-footer">' +
|
|
'<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
// Remove existing modal if present
|
|
const existingModal = document.getElementById('filePropertiesModal');
|
|
if (existingModal) {
|
|
existingModal.remove();
|
|
}
|
|
|
|
// Add modal to body and show
|
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
|
const modal = new bootstrap.Modal(document.getElementById('filePropertiesModal'));
|
|
modal.show();
|
|
|
|
// Remove modal when hidden
|
|
document.getElementById('filePropertiesModal').addEventListener('hidden.bs.modal', function() {
|
|
this.remove();
|
|
});
|
|
}
|
|
|
|
function createFilePropertiesContent(data) {
|
|
let html = '<div class="row">' +
|
|
'<div class="col-12">' +
|
|
'<h6 class="text-primary"><i class="bi bi-file-earmark me-1"></i>Basic Information</h6>' +
|
|
'<table class="table table-sm">' +
|
|
'<tr><td style="width: 120px;"><strong>Name:</strong></td><td>' + (data.name || 'N/A') + '</td></tr>' +
|
|
'<tr><td><strong>Full Path:</strong></td><td><code class="text-break">' + (data.full_path || 'N/A') + '</code></td></tr>' +
|
|
'<tr><td><strong>Type:</strong></td><td>' + (data.is_directory ? 'Directory' : 'File') + '</td></tr>';
|
|
|
|
if (!data.is_directory) {
|
|
html += '<tr><td><strong>Size:</strong></td><td>' + (data.size_formatted || (data.size ? formatBytes(data.size) : 'N/A')) + '</td></tr>' +
|
|
'<tr><td><strong>MIME Type:</strong></td><td>' + (data.mime_type || 'N/A') + '</td></tr>';
|
|
if (data.object_url) {
|
|
html += '<tr><td><strong>Object URL:</strong></td><td><code class="text-break">' + data.object_url + '</code>' +
|
|
' <button type="button" class="btn btn-outline-secondary btn-sm" title="Copy S3 URL" data-action="copy-url" data-url="' + data.object_url + '"><i class="bi bi-copy"></i></button></td></tr>';
|
|
}
|
|
}
|
|
|
|
html += '</table>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div class="row">' +
|
|
'<div class="col-md-6">' +
|
|
'<h6 class="text-primary"><i class="bi bi-clock me-1"></i>Timestamps</h6>' +
|
|
'<table class="table table-sm">';
|
|
|
|
if (data.modified_time) {
|
|
html += '<tr><td><strong>Modified:</strong></td><td>' + data.modified_time + '</td></tr>';
|
|
}
|
|
if (data.created_time) {
|
|
html += '<tr><td><strong>Created:</strong></td><td>' + data.created_time + '</td></tr>';
|
|
}
|
|
|
|
html += '</table>' +
|
|
'</div>' +
|
|
'<div class="col-md-6">' +
|
|
'<h6 class="text-primary"><i class="bi bi-shield me-1"></i>Permissions</h6>' +
|
|
'<table class="table table-sm">';
|
|
|
|
if (data.file_mode) {
|
|
const rwxPermissions = formatPermissions(data.file_mode, data.is_directory);
|
|
html += '<tr><td><strong>Permissions:</strong></td><td><code>' + rwxPermissions + '</code></td></tr>';
|
|
}
|
|
if (data.uid !== undefined) {
|
|
html += '<tr><td><strong>User ID:</strong></td><td>' + data.uid + '</td></tr>';
|
|
}
|
|
if (data.gid !== undefined) {
|
|
html += '<tr><td><strong>Group ID:</strong></td><td>' + data.gid + '</td></tr>';
|
|
}
|
|
|
|
html += '</table>' +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
// Add advanced info
|
|
html += '<div class="row">' +
|
|
'<div class="col-12">' +
|
|
'<h6 class="text-primary"><i class="bi bi-gear me-1"></i>Advanced</h6>' +
|
|
'<table class="table table-sm">';
|
|
|
|
if (data.chunk_count) {
|
|
html += '<tr><td style="width: 120px;"><strong>Chunks:</strong></td><td>' + data.chunk_count + '</td></tr>';
|
|
}
|
|
if (data.ttl_formatted) {
|
|
html += '<tr><td><strong>TTL:</strong></td><td>' + data.ttl_formatted + '</td></tr>';
|
|
}
|
|
|
|
html += '</table>' +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
// Add chunk details if available (show top 5)
|
|
if (data.chunks && data.chunks.length > 0) {
|
|
const chunksToShow = data.chunks.slice(0, 5);
|
|
html += '<div class="row mt-3">' +
|
|
'<div class="col-12">' +
|
|
'<h6 class="text-primary"><i class="bi bi-puzzle me-1"></i>Chunk Details' +
|
|
(data.chunk_count > 5 ? ' (Top 5 of ' + data.chunk_count + ')' : ' (' + data.chunk_count + ')') +
|
|
'</h6>' +
|
|
'<div class="table-responsive" style="max-height: 200px; overflow-y: auto;">' +
|
|
'<table class="table table-sm table-striped">' +
|
|
'<thead>' +
|
|
'<tr>' +
|
|
'<th>File ID</th>' +
|
|
'<th>Offset</th>' +
|
|
'<th>Size</th>' +
|
|
'<th>ETag</th>' +
|
|
'</tr>' +
|
|
'</thead>' +
|
|
'<tbody>';
|
|
|
|
chunksToShow.forEach(chunk => {
|
|
html += '<tr>' +
|
|
'<td><code class="small">' + (chunk.file_id || 'N/A') + '</code></td>' +
|
|
'<td>' + formatBytes(chunk.offset || 0) + '</td>' +
|
|
'<td>' + formatBytes(chunk.size || 0) + '</td>' +
|
|
'<td><code class="small">' + (chunk.e_tag || 'N/A') + '</code></td>' +
|
|
'</tr>';
|
|
});
|
|
|
|
html += '</tbody>' +
|
|
'</table>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
// Add extended attributes if present
|
|
if (data.extended && Object.keys(data.extended).length > 0) {
|
|
html += '<div class="row">' +
|
|
'<div class="col-12">' +
|
|
'<h6 class="text-primary"><i class="bi bi-tags me-1"></i>Extended Attributes</h6>' +
|
|
'<table class="table table-sm">';
|
|
|
|
for (const [key, value] of Object.entries(data.extended)) {
|
|
html += '<tr><td><strong>' + key + ':</strong></td><td>' + value + '</td></tr>';
|
|
}
|
|
|
|
html += '</table>' +
|
|
'</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
function uploadFile() {
|
|
const modal = new bootstrap.Modal(document.getElementById('uploadFileModal'));
|
|
modal.show();
|
|
}
|
|
|
|
function toggleSelectAll() {
|
|
const selectAllCheckbox = document.getElementById('selectAll');
|
|
const checkboxes = document.querySelectorAll('.file-checkbox');
|
|
|
|
checkboxes.forEach(checkbox => {
|
|
checkbox.checked = selectAllCheckbox.checked;
|
|
});
|
|
|
|
updateDeleteSelectedButton();
|
|
}
|
|
|
|
function updateDeleteSelectedButton() {
|
|
const checkboxes = document.querySelectorAll('.file-checkbox:checked');
|
|
const deleteBtn = document.getElementById('deleteSelectedBtn');
|
|
|
|
if (checkboxes.length > 0) {
|
|
deleteBtn.style.display = 'inline-block';
|
|
} else {
|
|
deleteBtn.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// Helper function to format bytes
|
|
function formatBytes(bytes) {
|
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
}
|
|
|
|
// Helper function to format permissions in rwxrwxrwx format
|
|
function formatPermissions(mode, isDirectory) {
|
|
// Check if mode is already in rwxrwxrwx format (e.g., "drwxr-xr-x" or "-rw-r--r--")
|
|
if (mode && (mode.startsWith('d') || mode.startsWith('-') || mode.startsWith('l')) && mode.length === 10) {
|
|
return mode; // Already formatted
|
|
}
|
|
|
|
// Convert to number - could be octal string or decimal
|
|
let permissions;
|
|
if (typeof mode === 'string') {
|
|
// Try parsing as octal first, then decimal
|
|
if (mode.startsWith('0') && mode.length <= 4) {
|
|
permissions = parseInt(mode, 8);
|
|
} else {
|
|
permissions = parseInt(mode, 10);
|
|
}
|
|
} else {
|
|
permissions = parseInt(mode, 10);
|
|
}
|
|
|
|
if (isNaN(permissions)) {
|
|
return isDirectory ? 'drwxr-xr-x' : '-rw-r--r--'; // Default fallback
|
|
}
|
|
|
|
// Handle Go's os.ModeDir conversion
|
|
// Go's os.ModeDir is 0x80000000 (2147483648), but Unix S_IFDIR is 0o40000 (16384)
|
|
let fileType = '-';
|
|
|
|
// Check for Go's os.ModeDir flag
|
|
if (permissions & 0x80000000) {
|
|
fileType = 'd';
|
|
}
|
|
// Check for standard Unix file type bits
|
|
else if ((permissions & 0xF000) === 0x4000) { // S_IFDIR (0o40000)
|
|
fileType = 'd';
|
|
} else if ((permissions & 0xF000) === 0x8000) { // S_IFREG (0o100000)
|
|
fileType = '-';
|
|
} else if ((permissions & 0xF000) === 0xA000) { // S_IFLNK (0o120000)
|
|
fileType = 'l';
|
|
} else if ((permissions & 0xF000) === 0x2000) { // S_IFCHR (0o020000)
|
|
fileType = 'c';
|
|
} else if ((permissions & 0xF000) === 0x6000) { // S_IFBLK (0o060000)
|
|
fileType = 'b';
|
|
} else if ((permissions & 0xF000) === 0x1000) { // S_IFIFO (0o010000)
|
|
fileType = 'p';
|
|
} else if ((permissions & 0xF000) === 0xC000) { // S_IFSOCK (0o140000)
|
|
fileType = 's';
|
|
}
|
|
// Fallback to isDirectory parameter if file type detection fails
|
|
else if (isDirectory) {
|
|
fileType = 'd';
|
|
}
|
|
|
|
// Permission bits (always use the lower 12 bits for permissions)
|
|
const owner = (permissions >> 6) & 7;
|
|
const group = (permissions >> 3) & 7;
|
|
const others = permissions & 7;
|
|
|
|
// Convert number to rwx format
|
|
function numToRwx(num) {
|
|
const r = (num & 4) ? 'r' : '-';
|
|
const w = (num & 2) ? 'w' : '-';
|
|
const x = (num & 1) ? 'x' : '-';
|
|
return r + w + x;
|
|
}
|
|
|
|
return fileType + numToRwx(owner) + numToRwx(group) + numToRwx(others);
|
|
}
|
|
|
|
function exportFileList() {
|
|
// Simple CSV export of file list
|
|
const rows = Array.from(document.querySelectorAll('#fileTable tbody tr')).map(row => {
|
|
const cells = row.querySelectorAll('td');
|
|
if (cells.length > 1) {
|
|
return {
|
|
name: cells[1].textContent.trim(),
|
|
size: cells[2].textContent.trim(),
|
|
type: cells[3].textContent.trim(),
|
|
modified: cells[4].textContent.trim(),
|
|
permissions: cells[5].textContent.trim()
|
|
};
|
|
}
|
|
return null;
|
|
}).filter(row => row !== null);
|
|
|
|
const csvContent = "data:text/csv;charset=utf-8," +
|
|
"Name,Size,Type,Modified,Permissions\n" +
|
|
rows.map(r => '"' + r.name + '","' + r.size + '","' + r.type + '","' + r.modified + '","' + r.permissions + '"').join("\n");
|
|
|
|
const encodedUri = encodeURI(csvContent);
|
|
const link = document.createElement("a");
|
|
link.setAttribute("href", encodedUri);
|
|
link.setAttribute("download", "files.csv");
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
}
|
|
|
|
// Handle file checkbox changes
|
|
document.addEventListener('change', function(e) {
|
|
if (e.target.classList.contains('file-checkbox')) {
|
|
updateDeleteSelectedButton();
|
|
}
|
|
});
|
|
</script>
|
|
}
|
|
|
|
|
|
|
|
func getFileIcon(mime string) string {
|
|
switch {
|
|
case strings.HasPrefix(mime, "image/"):
|
|
return "bi-image"
|
|
case strings.HasPrefix(mime, "video/"):
|
|
return "bi-camera-video"
|
|
case strings.HasPrefix(mime, "audio/"):
|
|
return "bi-music-note"
|
|
case strings.HasPrefix(mime, "text/"):
|
|
return "bi-file-earmark-text"
|
|
case mime == "application/pdf":
|
|
return "bi-file-earmark-pdf"
|
|
case mime == "application/zip" || strings.Contains(mime, "archive"):
|
|
return "bi-file-earmark-zip"
|
|
case mime == "application/json":
|
|
return "bi-file-earmark-code"
|
|
case strings.Contains(mime, "script") || strings.Contains(mime, "javascript"):
|
|
return "bi-file-earmark-code"
|
|
default:
|
|
return "bi-file-earmark"
|
|
}
|
|
}
|
|
|
|
func getMimeDisplayName(mime string) string {
|
|
switch mime {
|
|
case "text/plain":
|
|
return "Text"
|
|
case "text/html":
|
|
return "HTML"
|
|
case "application/json":
|
|
return "JSON"
|
|
case "application/pdf":
|
|
return "PDF"
|
|
case "image/jpeg":
|
|
return "JPEG"
|
|
case "image/png":
|
|
return "PNG"
|
|
case "image/gif":
|
|
return "GIF"
|
|
case "video/mp4":
|
|
return "MP4"
|
|
case "audio/mpeg":
|
|
return "MP3"
|
|
case "application/zip":
|
|
return "ZIP"
|
|
default:
|
|
if strings.HasPrefix(mime, "image/") {
|
|
return "Image"
|
|
} else if strings.HasPrefix(mime, "video/") {
|
|
return "Video"
|
|
} else if strings.HasPrefix(mime, "audio/") {
|
|
return "Audio"
|
|
} else if strings.HasPrefix(mime, "text/") {
|
|
return "Text"
|
|
}
|
|
return "File"
|
|
}
|
|
}
|