Files
seaweedFS/weed/admin/view/app/file_browser.templ
Chris Lu 5a7c74feac migrate IAM policies to multi-file storage (#8114)
* Add IAM gRPC service definition

- Add GetConfiguration/PutConfiguration for config management
- Add CreateUser/GetUser/UpdateUser/DeleteUser/ListUsers for user management
- Add CreateAccessKey/DeleteAccessKey/GetUserByAccessKey for access key management
- Methods mirror existing IAM HTTP API functionality

* Add IAM gRPC handlers on filer server

- Implement IamGrpcServer with CredentialManager integration
- Handle configuration get/put operations
- Handle user CRUD operations
- Handle access key create/delete operations
- All methods delegate to CredentialManager for actual storage

* Wire IAM gRPC service to filer server

- Add CredentialManager field to FilerOption and FilerServer
- Import credential store implementations in filer command
- Initialize CredentialManager from credential.toml if available
- Register IAM gRPC service on filer gRPC server
- Enable credential management via gRPC alongside existing filer services

* Regenerate IAM protobuf with gRPC service methods

* fix: compilation error in DeleteUser

* fix: address code review comments for IAM migration

* feat: migrate policies to multi-file layout and fix identity duplicated content

* refactor: remove configuration.json and migrate Service Accounts to multi-file layout

* refactor: standardize Service Accounts as distinct store entities and fix Admin Server persistence

* config: set ServiceAccountsDirectory to /etc/iam/service_accounts

* Fix Chrome dialog auto-dismiss with Bootstrap modals

- Add modal-alerts.js library with Bootstrap modal replacements
- Replace all 15 confirm() calls with showConfirm/showDeleteConfirm
- Auto-override window.alert() for all alert() calls
- Fixes Chrome 132+ aggressively blocking native dialogs

* Upgrade Bootstrap from 5.3.2 to 5.3.8

* Fix syntax error in object_store_users.templ - remove duplicate closing braces

* create policy

* display errors

* migrate to multi-file policies

* address PR feedback: use showDeleteConfirm and showErrorMessage in policies.templ, refine migration check

* Update policies_templ.go

* add service account to iam grpc

* iam: fix potential path traversal in policy names by validating name pattern

* iam: add GetServiceAccountByAccessKey to CredentialStore interface

* iam: implement service account support for PostgresStore

Includes full CRUD operations and efficient lookup by access key.

* iam: implement GetServiceAccountByAccessKey for filer_etc, grpc, and memory stores

Provides efficient lookup of service accounts by access key where possible,
with linear scan fallbacks for file-based stores.

* iam: remove filer_multiple support

Deleted its implementation and references in imports, scaffold config,
and core interface constants. Redundant with filer_etc.

* clear comment

* dash: robustify service account construction

- Guard against nil sa.Credential when constructing responses
- Fix Expiration logic to only set if > 0, avoiding Unix epoch 1970
- Ensure consistency across Get, Create, and Update handlers

* credential/filer_etc: improve error propagation in configuration handlers

- Return error from loadServiceAccountsFromMultiFile to callers
- Ensure listEntries errors in SaveConfiguration (cleanup logic) are
  propagated unless they are "not found" failures.
- Fixes potential silent failures during IAM configuration sync.

* credential/filer_etc: add existence check to CreateServiceAccount

Ensures consistency with other stores by preventing accidental overwrite
of existing service accounts during creation.

* credential/memory: improve store robustness and Reset logic

- Enforce ID immutability in UpdateServiceAccount to prevent orphans
- Update Reset() to also clear the policies map, ensuring full state
  cleanup for tests.

* dash: improve service account robustness and policy docs

- Wrap parent user lookup errors to preserve context
- Strictly validate Status field in UpdateServiceAccount
- Add deprecation comments to legacy policy management methods

* credential/filer_etc: protect against path traversal in service accounts

Implemented ID validation (alphanumeric, underscores, hyphens) and applied
it to Get, Save, and Delete operations to ensure no directory traversal
via saId.json filenames.

* credential/postgres: improve robustness and cleanup comments

- Removed brainstorming comments in GetServiceAccountByAccessKey
- Added missing rows.Err() check during iteration
- Properly propagate Scan and Unmarshal errors instead of swallowing them

* admin: unify UI alerts and confirmations using Bootstrap modals

- Updated modal-alerts.js with improved automated alert type detection
- Replaced native alert() and confirm() with showAlert(), showConfirm(),
  and showDeleteConfirm() across various Templ components
- Improved UX for delete operations by providing better context and styling
- Ensured consistent error reporting across IAM and Maintenance views

* admin: additional UI consistency fixes for alerts and confirmations

- Replaced native alert() and confirm() with Bootstrap modals in:
  - EC volumes (repair flow)
  - Collection details (repair flow)
  - File browser (properties and delete)
  - Maintenance config schema (save and reset)
- Improved delete confirmation in file browser with item context
- Ensured consistent success/error/info styling for all feedbacks

* make

* iam: add GetServiceAccountByAccessKey RPC and update GetConfiguration

* iam: implement GetServiceAccountByAccessKey on server and client

* iam: centralize policy and service account validation

* iam: optimize MemoryStore service account lookups with indexing

* iam: fix postgres service_accounts table and optimize lookups

* admin: refactor modal alerts and clean up dashboard logic

* admin: fix EC shards table layout mismatch

* admin: URL-encode IAM path parameters for safety

* admin: implement pauseWorker logic in maintenance view

* iam: add rows.Err() check to postgres ListServiceAccounts

* iam: standardize ErrServiceAccountNotFound across credential stores

* iam: map ErrServiceAccountNotFound to codes.NotFound in DeleteServiceAccount

* iam: refine service account store logic, errors and schema

* iam: add validation to GetServiceAccountByAccessKey

* admin: refine modal titles and ensure URL safety

* admin: address bot review comments for alerts and async usage

* iam: fix syntax error by restoring missing function declaration

* [FilerEtcStore] improve error handling in CreateServiceAccount

Refine error handling to provide clearer messages when checking for
existing service accounts.

* [PostgresStore] add nil guards and validation to service account methods

Ensure input parameters are not nil and required IDs are present
to prevent runtime panics and ensure data integrity.

* [JS] add shared IAM utility script

Consolidate common IAM operations like deleteUser and deleteAccessKey
into a shared utility script for better maintainability.

* [View] include shared IAM utilities in layout

Include iam-utils.js in the main layout to make IAM functions
available across all administrative pages.

* [View] refactor IAM logic and restore async in EC Shards view

Remove redundant local IAM functions and ensure that delete
confirmation callbacks are properly marked as async.

* [View] consolidate IAM logic in Object Store Users view

Remove redundant local definitions of deleteUser and deleteAccessKey,
relying on the shared utilities instead.

* [View] update generated templ files for UI consistency

* credential/postgres: remove redundant name column from service_accounts table

The id is already used as the unique identifier and was being copied to the name column.
This removes the name column from the schema and updates the INSERT/UPDATE queries.

* credential/filer_etc: improve logging for policy migration failures

Added Errorf log if AtomicRenameEntry fails during migration to ensure visibility of common failure points.

* credential: allow uppercase characters in service account ID username

Updated ServiceAccountIdPattern to allow [A-Za-z0-9_-]+ for the username component,
matching the actual service account creation logic which uses the parent user name directly.

* Update object_store_users_templ.go

* admin: fix ec_shards pagination to handle numeric page arguments

Updated goToPage in cluster_ec_shards.templ to accept either an Event
or a numeric page argument. This prevents errors when goToPage(1)
is called directly. Corrected both the .templ source and generated Go code.

* credential/filer_etc: improve service account storage robustness

Added nil guard to saveServiceAccount, updated GetServiceAccount
to return ErrServiceAccountNotFound for empty data, and improved
deleteServiceAccount to handle response-level Filer errors.
2026-01-26 11:28:23 -08:00

770 lines
26 KiB
Plaintext

package app
import (
"fmt"
"path/filepath"
"strings"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
script changePageSize(path string, lastFileName string) {
window.location.href = '/files?path=' + encodeURIComponent(path) + '&lastFileName=' + encodeURIComponent(lastFileName) + '&limit=' + this.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.IsBucketPath && data.BucketName != "" {
<i class="fas fa-cube me-2"></i>S3 Bucket: {data.BucketName}
} else {
<i class="fas fa-folder-open me-2"></i>File Browser
}
</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
if data.IsBucketPath && data.BucketName != "" {
<a href="/object-store/buckets" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Back to Buckets
</a>
}
<button type="button" class="btn btn-sm btn-outline-primary" onclick="createFolder()">
<i class="fas fa-folder-plus me-1"></i>New Folder
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="uploadFile()">
<i class="fas fa-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="fas fa-trash me-1"></i>Delete Selected
</button>
<button type="button" class="btn btn-sm btn-outline-info" onclick="exportFileList()">
<i class="fas fa-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={ templ.SafeURL(fmt.Sprintf("/files?path=%s", crumb.Path)) } class="text-decoration-none">
{ crumb.Name }
</a>
</li>
} else {
<li class="breadcrumb-item">
<a href={ templ.SafeURL(fmt.Sprintf("/files?path=%s", crumb.Path)) } class="text-decoration-none">
if crumb.Name == "Root" {
<i class="fas fa-home 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="fas fa-folder-open me-2"></i>
if data.CurrentPath == "/" {
<a href="/files?path=/" class="text-decoration-none text-primary">Root Directory</a>
} else if data.CurrentPath == "/buckets" {
<a href="/files?path=/buckets" class="text-decoration-none text-primary">Object Store Buckets Directory</a>
<a href="/object-store/buckets" class="btn btn-sm btn-outline-primary ms-2">
<i class="fas fa-cube me-1"></i>Manage Buckets
</a>
} else {
<a href={ templ.SafeURL(fmt.Sprintf("/files?path=%s", 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 class="form-select form-select-sm" style="width: 70px; font-size: 0.875rem;" onchange={ changePageSize(data.CurrentPath, data.CurrentLastFileName) }>
<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={ templ.SafeURL(fmt.Sprintf("/files?path=%s&lastFileName=%s&limit=%d", data.CurrentPath, data.LastFileName, data.PageSize)) } class="btn btn-outline-primary" title="Next page">
Next <i class="fas fa-angle-right"></i>
</a>
} else {
<button class="btn btn-outline-secondary" disabled title="Next page">
Next <i class="fas fa-angle-right"></i>
</button>
}
if data.ParentPath != data.CurrentPath {
<a href={ templ.SafeURL(fmt.Sprintf("/files?path=%s", data.ParentPath)) } class="btn btn-outline-secondary" title="Go up one directory">
<i class="fas fa-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="fas fa-folder text-warning me-2"></i>
<a href={ templ.SafeURL(fmt.Sprintf("/files?path=%s", entry.FullPath)) } class="text-decoration-none">
{ entry.Name }
</a>
} else {
<i class={ fmt.Sprintf("fas %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="fas fa-download"></i>
</button>
<button type="button" class="btn btn-outline-info btn-sm" title="View" data-action="view" data-path={ entry.FullPath }>
<i class="fas fa-eye"></i>
</button>
}
<button type="button" class="btn btn-outline-secondary btn-sm" title="Properties" data-action="properties" data-path={ entry.FullPath }>
<i class="fas fa-info-circle"></i>
</button>
<button type="button" class="btn btn-outline-danger btn-sm" title="Delete" data-action="delete" data-path={ entry.FullPath }>
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
} else {
<div class="text-center py-5">
<i class="fas fa-folder-open fa-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 class="form-select form-select-sm" style="width: auto;" onchange={ changePageSize(data.CurrentPath, data.CurrentLastFileName) }>
<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={ templ.SafeURL(fmt.Sprintf("/files?path=%s&lastFileName=%s&limit=%d", data.CurrentPath, data.LastFileName, data.PageSize)) } class="btn btn-outline-primary">
Next <i class="fas fa-angle-right ms-1"></i>
</a>
} else {
<button class="btn btn-outline-secondary" disabled>
Next <i class="fas fa-angle-right ms-1"></i>
</button>
}
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<small class="text-muted">
<i class="fas fa-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="fas fa-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="fas fa-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="fas fa-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">
<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.
</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="fas fa-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, delete)
document.addEventListener('click', function(e) {
const button = e.target.closest('[data-action]');
if (!button) return;
const action = button.getAttribute('data-action');
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 '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('/api/files/download?path=' + encodeURIComponent(path), '_blank');
}
function viewFile(path) {
// Open file viewer in new tab
window.open('/api/files/view?path=' + encodeURIComponent(path), '_blank');
}
function showFileProperties(path) {
// Fetch file properties and show in modal
fetch('/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="fas fa-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="fas fa-file 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>';
}
html += '</table>' +
'</div>' +
'</div>' +
'<div class="row">' +
'<div class="col-md-6">' +
'<h6 class="text-primary"><i class="fas fa-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="fas fa-shield-alt 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="fas fa-cog 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="fas fa-puzzle-piece 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="fas fa-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 "fa-image"
case strings.HasPrefix(mime, "video/"):
return "fa-video"
case strings.HasPrefix(mime, "audio/"):
return "fa-music"
case strings.HasPrefix(mime, "text/"):
return "fa-file-text"
case mime == "application/pdf":
return "fa-file-pdf"
case mime == "application/zip" || strings.Contains(mime, "archive"):
return "fa-file-archive"
case mime == "application/json":
return "fa-file-code"
case strings.Contains(mime, "script") || strings.Contains(mime, "javascript"):
return "fa-file-code"
default:
return "fa-file"
}
}
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"
}
}