Release v0.1.0 Beta

This commit is contained in:
2025-11-21 22:01:34 +08:00
commit f400cedf02
40 changed files with 10720 additions and 0 deletions

11
templates/404.html Normal file
View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block content %}
<div class="text-center py-5">
<p class="text-uppercase text-muted small mb-2">HTTP 404</p>
<h1 class="display-6 mb-3">We can't find that page</h1>
<p class="text-muted mb-4">The requested console route isn't available in MyFSIO. Double-check the URL or head back to your buckets.</p>
<div class="d-flex flex-wrap justify-content-center gap-3">
<a class="btn btn-primary" href="{{ url_for('ui.buckets_overview') }}">Return to buckets</a>
</div>
</div>
{% endblock %}

11
templates/500.html Normal file
View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block content %}
<div class="text-center py-5">
<p class="text-uppercase text-muted small mb-2">HTTP 500</p>
<h1 class="display-6 mb-3">Internal Server Error</h1>
<p class="text-muted mb-4">Something went wrong on our end. Please try again later or contact support.</p>
<div class="d-flex flex-wrap justify-content-center gap-3">
<a class="btn btn-primary" href="{{ url_for('ui.buckets_overview') }}">Return to buckets</a>
</div>
</div>
{% endblock %}

242
templates/base.html Normal file
View File

@@ -0,0 +1,242 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content="{{ csrf_token() }}" />
<title>MyFSIO Console</title>
<link rel="icon" type="image/png" href="{{ url_for('static', filename='images/MyFISO.png') }}" />
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='images/MyFISO.ico') }}" />
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN"
crossorigin="anonymous"
/>
<script>
(function () {
try {
const stored = localStorage.getItem('myfsio-theme');
const theme = stored === 'dark' ? 'dark' : 'light';
document.documentElement.dataset.bsTheme = theme;
document.documentElement.dataset.theme = theme;
} catch (err) {
document.documentElement.dataset.bsTheme = 'light';
document.documentElement.dataset.theme = 'light';
}
})();
</script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}" />
</head>
<body>
<nav class="navbar navbar-expand-lg myfsio-nav shadow-sm">
<div class="container-fluid">
<a class="navbar-brand fw-semibold" href="{{ url_for('ui.buckets_overview') }}">
<img
src="{{ url_for('static', filename='images/MyFISO.png') }}"
alt="MyFSIO logo"
class="myfsio-logo"
width="32"
height="32"
decoding="async"
/>
<span class="myfsio-title">MyFSIO</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navContent" aria-controls="navContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
{% if principal %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('ui.buckets_overview') }}">Buckets</a>
</li>
<li class="nav-item">
<a class="nav-link {% if not can_manage_iam %}nav-link-muted{% endif %}" href="{{ url_for('ui.iam_dashboard') }}">
IAM
{% if not can_manage_iam %}<span class="badge ms-2 text-bg-warning">Restricted</span>{% endif %}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if not can_manage_iam %}nav-link-muted{% endif %}" href="{{ url_for('ui.connections_dashboard') }}">
Connections
{% if not can_manage_iam %}<span class="badge ms-2 text-bg-warning">Restricted</span>{% endif %}
</a>
</li>
{% endif %}
{% if principal %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('ui.docs_page') }}">Docs</a>
</li>
{% endif %}
</ul>
<div class="ms-lg-auto d-flex align-items-center gap-3 text-light flex-wrap">
<button
class="btn btn-outline-light btn-sm theme-toggle"
type="button"
id="themeToggle"
aria-pressed="false"
aria-label="Toggle dark mode"
>
<span id="themeToggleLabel" class="visually-hidden">Toggle dark mode</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
class="theme-icon"
id="themeToggleSun"
viewBox="0 0 16 16"
aria-hidden="true"
>
<path
d="M8 11.5a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7zm0 1.5a5 5 0 1 0 0-10 5 5 0 0 0 0 10zM8 0a.5.5 0 0 1 .5.5v1.555a.5.5 0 0 1-1 0V.5A.5.5 0 0 1 8 0zm0 12.945a.5.5 0 0 1 .5.5v2.055a.5.5 0 0 1-1 0v-2.055a.5.5 0 0 1 .5-.5zM2.343 2.343a.5.5 0 0 1 .707 0l1.1 1.1a.5.5 0 1 1-.708.707l-1.1-1.1a.5.5 0 0 1 0-.707zm9.507 9.507a.5.5 0 0 1 .707 0l1.1 1.1a.5.5 0 1 1-.707.708l-1.1-1.1a.5.5 0 0 1 0-.708zM0 8a.5.5 0 0 1 .5-.5h1.555a.5.5 0 0 1 0 1H.5A.5.5 0 0 1 0 8zm12.945 0a.5.5 0 0 1 .5-.5H15.5a.5.5 0 0 1 0 1h-2.055a.5.5 0 0 1-.5-.5zM2.343 13.657a.5.5 0 0 1 0-.707l1.1-1.1a.5.5 0 1 1 .708.707l-1.1 1.1a.5.5 0 0 1-.708 0zm9.507-9.507a.5.5 0 0 1 0-.707l1.1-1.1a.5.5 0 0 1 .707.708l-1.1 1.1a.5.5 0 0 1-.707 0z"
/>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
class="theme-icon d-none"
id="themeToggleMoon"
viewBox="0 0 16 16"
aria-hidden="true"
>
<path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"/>
<path d="M10.794 3.148a.217.217 0 0 1 .412 0l.387 1.162c.173.518.579.924 1.097 1.097l1.162.387a.217.217 0 0 1 0 .412l-1.162.387a1.734 1.734 0 0 0-1.097 1.097l-.387 1.162a.217.217 0 0 1-.412 0l-.387-1.162A1.734 1.734 0 0 0 9.31 6.593l-1.162-.387a.217.217 0 0 1 0-.412l1.162-.387a1.734 1.734 0 0 0 1.097-1.097l.387-1.162zM13.863.099a.145.145 0 0 1 .274 0l.258.774c.115.346.386.617.732.732l.774.258a.145.145 0 0 1 0 .274l-.774.258a1.156 1.156 0 0 0-.732.732l-.258.774a.145.145 0 0 1-.274 0l-.258-.774a1.156 1.156 0 0 0-.732-.732l-.774-.258a.145.145 0 0 1 0-.274l.774-.258c.346-.115.617-.386.732-.732L13.863.1z"/>
</svg>
</button>
{% if principal %}
<div class="text-end small">
<div class="fw-semibold" title="{{ principal.display_name }}">{{ principal.display_name | truncate(20, true) }}</div>
<div class="opacity-75">{{ principal.access_key }}</div>
</div>
<form method="post" action="{{ url_for('ui.logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<button class="btn btn-outline-light btn-sm" type="submit">Sign out</button>
</form>
{% endif %}
</div>
</div>
</div>
</nav>
<main class="container py-4">
{% block content %}{% endblock %}
</main>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="liveToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<strong class="me-auto" id="toastTitle">Notification</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body" id="toastMessage">
Hello, world! This is a toast message.
</div>
</div>
</div>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
crossorigin="anonymous"
></script>
<script>
window.myfsioCsrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
window.getCsrfToken = () => window.myfsioCsrfToken;
(function () {
const originalFetch = window.fetch;
window.fetch = function (input, init) {
init = init || {};
const method = (init.method || 'GET').toUpperCase();
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
const headers = new Headers(init.headers || {});
if (window.myfsioCsrfToken) {
headers.set('X-CSRFToken', window.myfsioCsrfToken);
}
init.headers = headers;
}
return originalFetch.call(this, input, init);
};
})();
</script>
<script>
(function () {
const storageKey = 'myfsio-theme';
const toggle = document.getElementById('themeToggle');
const label = document.getElementById('themeToggleLabel');
const sunIcon = document.getElementById('themeToggleSun');
const moonIcon = document.getElementById('themeToggleMoon');
const applyTheme = (theme) => {
document.documentElement.dataset.bsTheme = theme;
document.documentElement.dataset.theme = theme;
try {
localStorage.setItem(storageKey, theme);
} catch (err) {
/* localStorage unavailable */
}
if (label) {
label.textContent = theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
}
if (toggle) {
toggle.setAttribute('aria-pressed', theme === 'dark' ? 'true' : 'false');
toggle.setAttribute('title', theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode');
toggle.setAttribute('aria-label', theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode');
}
if (sunIcon && moonIcon) {
const isDark = theme === 'dark';
sunIcon.classList.toggle('d-none', !isDark);
moonIcon.classList.toggle('d-none', isDark);
}
};
const current = document.documentElement.dataset.bsTheme || 'light';
applyTheme(current);
toggle?.addEventListener('click', () => {
const next = document.documentElement.dataset.bsTheme === 'dark' ? 'light' : 'dark';
applyTheme(next);
});
})();
</script>
<script>
// Toast utility
window.showToast = function(message, title = 'Notification', type = 'info') {
const toastEl = document.getElementById('liveToast');
const toastTitle = document.getElementById('toastTitle');
const toastMessage = document.getElementById('toastMessage');
toastTitle.textContent = title;
toastMessage.textContent = message;
// Reset classes
toastEl.classList.remove('text-bg-primary', 'text-bg-success', 'text-bg-danger', 'text-bg-warning');
if (type === 'success') toastEl.classList.add('text-bg-success');
if (type === 'error') toastEl.classList.add('text-bg-danger');
if (type === 'warning') toastEl.classList.add('text-bg-warning');
const toast = new bootstrap.Toast(toastEl);
toast.show();
};
</script>
<script>
(function () {
// Show flashed messages as toasts
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
// Map Flask categories to Toast types
// Flask: success, danger, warning, info
// Toast: success, error, warning, info
var type = "{{ category }}";
if (type === "danger") type = "error";
window.showToast({{ message | tojson | safe }}, "Notification", type);
{% endfor %}
{% endif %}
{% endwith %}
})();
</script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

1752
templates/bucket_detail.html Normal file

File diff suppressed because it is too large Load Diff

198
templates/buckets.html Normal file
View File

@@ -0,0 +1,198 @@
{% extends "base.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1 fw-bold">Buckets</h1>
<p class="text-muted mb-0">Manage your S3-compatible storage containers.</p>
</div>
<button class="btn btn-primary shadow-sm" data-bs-toggle="modal" data-bs-target="#createBucketModal">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-lg me-1" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
</svg>
Create Bucket
</button>
</div>
<div class="d-flex justify-content-between align-items-center mb-3 gap-3">
<div class="position-relative flex-grow-1" style="max-width: 300px;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-search position-absolute top-50 start-0 translate-middle-y ms-3 text-muted" viewBox="0 0 16 16">
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
</svg>
<input type="search" class="form-control ps-5" id="bucket-search" placeholder="Filter buckets..." aria-label="Search buckets">
</div>
<div class="btn-group" role="group" aria-label="View toggle">
<input type="radio" class="btn-check" name="view-toggle" id="view-grid" autocomplete="off" checked>
<label class="btn btn-outline-secondary" for="view-grid" title="Grid view">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-grid-fill" viewBox="0 0 16 16">
<path d="M1 2.5A1.5 1.5 0 0 1 2.5 1h3A1.5 1.5 0 0 1 7 2.5v3A1.5 1.5 0 0 1 5.5 7h-3A1.5 1.5 0 0 1 1 5.5v-3zm8 0A1.5 1.5 0 0 1 10.5 1h3A1.5 1.5 0 0 1 15 2.5v3A1.5 1.5 0 0 1 13.5 7h-3A1.5 1.5 0 0 1 9 5.5v-3zm-8 8A1.5 1.5 0 0 1 2.5 9h3A1.5 1.5 0 0 1 7 10.5v3A1.5 1.5 0 0 1 5.5 15h-3A1.5 1.5 0 0 1 1 13.5v-3zm8 0A1.5 1.5 0 0 1 10.5 9h3A1.5 1.5 0 0 1 15 10.5v3A1.5 1.5 0 0 1 13.5 15h-3A1.5 1.5 0 0 1 9 13.5v-3z"/>
</svg>
</label>
<input type="radio" class="btn-check" name="view-toggle" id="view-list" autocomplete="off">
<label class="btn btn-outline-secondary" for="view-list" title="List view">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-list-ul" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M5 11.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm-3 1a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm0 4a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm0 4a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/>
</svg>
</label>
</div>
</div>
<div class="row g-3" id="buckets-container">
{% for bucket in buckets %}
<div class="col-md-6 col-xl-4 bucket-item">
<div class="card h-100 shadow-sm border-0 bucket-card" data-bucket-row data-href="{{ bucket.detail_url }}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<div class="d-flex align-items-center gap-2">
<div class="bg-primary-subtle text-primary rounded p-2">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-hdd-network" viewBox="0 0 16 16">
<path d="M4.5 5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1zM3 4.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0z"/>
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2H8.5v3a1.5 1.5 0 0 1 1.5 1.5v3.375a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5V11.5a.5.5 0 0 1 .5-.5h1V9.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.5a.5.5 0 0 1 .5.5h1v3.375a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5V11.5a.5.5 0 0 1 .5-.5h1V9.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.5a.5.5 0 0 1 .5.5h1v3.375a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5V11.5a.5.5 0 0 1 .5-.5h1V9.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.5a.5.5 0 0 1 .5.5h1v3.375a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5V11.5a.5.5 0 0 1 .5-.5h1V9.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.5a.5.5 0 0 1 .5.5h1V13.5a1.5 1.5 0 0 1 1.5-1.5h3V7H2a2 2 0 0 1-2-2V4zm1 0a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v1z"/>
</svg>
</div>
<h5 class="card-title mb-0 text-break">{{ bucket.meta.name }}</h5>
</div>
<span class="badge {{ bucket.access_badge }} rounded-pill">{{ bucket.access_label }}</span>
</div>
<div class="d-flex justify-content-between align-items-end mt-4">
<div>
<div class="text-muted small mb-1">Storage Used</div>
<div class="fw-semibold">{{ bucket.summary.human_size }}</div>
</div>
<div class="text-end">
<div class="text-muted small mb-1">Objects</div>
<div class="fw-semibold">{{ bucket.summary.objects }}</div>
</div>
</div>
</div>
<div class="card-footer bg-transparent border-top-0 pt-0 pb-3">
<small class="text-muted">Created {{ bucket.meta.created_at.strftime('%b %d, %Y') }}</small>
</div>
</div>
</div>
{% else %}
<div class="col-12">
<div class="text-center py-5 bg-panel rounded-3 border border-dashed">
<div class="mb-3 text-muted">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-bucket" viewBox="0 0 16 16">
<path d="M2.522 5H2a.5.5 0 0 0-.494.574l1.372 9.149A1.5 1.5 0 0 0 4.36 16h7.278a1.5 1.5 0 0 0 1.483-1.277l1.373-9.149A.5.5 0 0 0 14 5h-.522A5.5 5.5 0 0 0 2.522 5zm1.005 0a4.5 4.5 0 0 1 8.945 0H3.527z"/>
</svg>
</div>
<h5>No buckets found</h5>
<p class="text-muted mb-4">Get started by creating your first storage bucket.</p>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createBucketModal">Create Bucket</button>
</div>
</div>
{% endfor %}
</div>
<div class="modal fade" id="createBucketModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5">Create bucket</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="{{ url_for('ui.create_bucket') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="modal-body">
<label class="form-label">Bucket name</label>
<input class="form-control" type="text" name="bucket_name" pattern="[a-z0-9.-]{3,63}" placeholder="team-assets" required />
<div class="form-text">Must be 3-63 chars, lowercase letters, numbers, dots, or hyphens.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-primary" type="submit">Create</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
{{ super() }}
<script>
(function () {
// Search functionality
const searchInput = document.getElementById('bucket-search');
const bucketItems = document.querySelectorAll('.bucket-item');
const noBucketsMsg = document.querySelector('.text-center.py-5'); // The "No buckets found" empty state
if (searchInput) {
searchInput.addEventListener('input', (e) => {
const term = e.target.value.toLowerCase();
let visibleCount = 0;
bucketItems.forEach(item => {
const name = item.querySelector('.card-title').textContent.toLowerCase();
if (name.includes(term)) {
item.classList.remove('d-none');
visibleCount++;
} else {
item.classList.add('d-none');
}
});
});
}
// View toggle functionality
const viewGrid = document.getElementById('view-grid');
const viewList = document.getElementById('view-list');
const container = document.getElementById('buckets-container');
const items = document.querySelectorAll('.bucket-item');
const cards = document.querySelectorAll('.bucket-card');
function setView(view) {
if (view === 'list') {
items.forEach(item => {
item.classList.remove('col-md-6', 'col-xl-4');
item.classList.add('col-12');
});
cards.forEach(card => {
card.classList.remove('h-100');
// Optional: Add flex-row to card-body content if we want a horizontal layout
// For now, full-width stacked cards is a good list view
});
localStorage.setItem('bucket-view-pref', 'list');
} else {
items.forEach(item => {
item.classList.remove('col-12');
item.classList.add('col-md-6', 'col-xl-4');
});
cards.forEach(card => {
card.classList.add('h-100');
});
localStorage.setItem('bucket-view-pref', 'grid');
}
}
if (viewGrid && viewList) {
viewGrid.addEventListener('change', () => setView('grid'));
viewList.addEventListener('change', () => setView('list'));
// Restore preference
const pref = localStorage.getItem('bucket-view-pref');
if (pref === 'list') {
viewList.checked = true;
setView('list');
}
}
const rows = document.querySelectorAll('[data-bucket-row]');
rows.forEach((row) => {
row.addEventListener('click', (event) => {
if (event.target.closest('[data-ignore-row-click]')) {
return;
}
const href = row.dataset.href;
if (href) {
window.location.href = href;
}
});
row.style.cursor = 'pointer';
});
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,89 @@
{% extends "base.html" %}
{% block title %}Connections - S3 Compatible Storage{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-md-12">
<h2>Remote Connections</h2>
<p class="text-muted">Manage connections to other S3-compatible services for replication.</p>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-header">
Add New Connection
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('ui.create_connection') }}">
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input type="text" class="form-control" id="name" name="name" required placeholder="e.g. Production Backup">
</div>
<div class="mb-3">
<label for="endpoint_url" class="form-label">Endpoint URL</label>
<input type="url" class="form-control" id="endpoint_url" name="endpoint_url" required placeholder="https://s3.us-east-1.amazonaws.com">
</div>
<div class="mb-3">
<label for="region" class="form-label">Region</label>
<input type="text" class="form-control" id="region" name="region" value="us-east-1">
</div>
<div class="mb-3">
<label for="access_key" class="form-label">Access Key</label>
<input type="text" class="form-control" id="access_key" name="access_key" required>
</div>
<div class="mb-3">
<label for="secret_key" class="form-label">Secret Key</label>
<input type="password" class="form-control" id="secret_key" name="secret_key" required>
</div>
<button type="submit" class="btn btn-primary">Add Connection</button>
</form>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card">
<div class="card-header">
Existing Connections
</div>
<div class="card-body">
{% if connections %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>Endpoint</th>
<th>Region</th>
<th>Access Key</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for conn in connections %}
<tr>
<td>{{ conn.name }}</td>
<td>{{ conn.endpoint_url }}</td>
<td>{{ conn.region }}</td>
<td><code>{{ conn.access_key }}</code></td>
<td>
<form method="POST" action="{{ url_for('ui.delete_connection', connection_id=conn.id) }}" onsubmit="return confirm('Are you sure?');" style="display: inline;">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted text-center my-4">No remote connections configured.</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

14
templates/csrf_error.html Normal file
View File

@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block content %}
<div class="text-center py-5">
<p class="text-uppercase text-muted small mb-2">Security Check Failed</p>
<h1 class="display-6 mb-3">Session Expired or Invalid</h1>
<p class="text-muted mb-4">Your session may have expired or the form submission was invalid. Please refresh the page and try again.</p>
{% if reason %}
<p class="text-danger small">{{ reason }}</p>
{% endif %}
<div class="d-flex flex-wrap justify-content-center gap-3">
<a class="btn btn-primary" href="{{ url_for('ui.buckets_overview') }}">Return to buckets</a>
</div>
</div>
{% endblock %}

376
templates/docs.html Normal file
View File

@@ -0,0 +1,376 @@
{% extends "base.html" %}
{% block content %}
<section class="docs-hero p-4 p-md-5 rounded-4 mb-4">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
<div>
<p class="text-uppercase fw-semibold small text-white-50 mb-2">Documentation</p>
<h1 class="display-6 fw-semibold mb-2">Your guide to MyFSIO</h1>
<p class="lead mb-0 text-light">Follow these steps to install, authenticate, master the console, and automate everything through the API.</p>
</div>
<div class="docs-callout text-light">
<div class="small text-uppercase opacity-75">API base URL</div>
<code class="fs-6 text-wrap">{{ api_base }}</code>
</div>
</div>
</section>
<div class="row g-4">
<div class="col-xl-8">
<article id="setup" class="card shadow-sm docs-section">
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-3">
<span class="docs-section-kicker">01</span>
<h2 class="h4 mb-0">Set up &amp; run locally</h2>
</div>
<p class="text-muted">Prepare a virtual environment, install dependencies, and launch both servers for a complete console + API experience.</p>
<ol class="docs-steps">
<li>Install Python 3.11+ plus system build tools.</li>
<li>Create a virtual environment and install <code>requirements.txt</code>.</li>
<li>Start the services with <code>python run.py</code>.</li>
</ol>
<pre class="mb-3"><code class="language-bash">python -m venv .venv
. .venv/Scripts/activate # PowerShell: .\\.venv\\Scripts\\Activate.ps1
pip install -r requirements.txt
# Run both API and UI
python run.py
# Or run individually
python run.py --mode api
python run.py --mode ui
</code></pre>
<p class="small text-muted mb-0">Configuration lives in <code>app/config.py</code>; override variables via the shell (e.g., <code>STORAGE_ROOT</code>, <code>API_BASE_URL</code>, <code>SECRET_KEY</code>, <code>MAX_UPLOAD_SIZE</code>).</p>
</div>
</article>
<article id="auth" class="card shadow-sm docs-section">
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-3">
<span class="docs-section-kicker">02</span>
<h2 class="h4 mb-0">Authenticate &amp; manage IAM</h2>
</div>
<p class="text-muted">MyFSIO seeds <code>data/.myfsio.sys/config/iam.json</code> with <code>localadmin/localadmin</code>. Sign in once, rotate it, then grant least-privilege access to teammates and tools.</p>
<div class="docs-highlight mb-3">
<ol class="mb-0">
<li>Visit <code>/ui/login</code>, enter the bootstrap credentials, and rotate them immediately from the IAM page.</li>
<li>Create additional users with descriptive display names and AWS-style inline policies (for example <code>{"bucket": "*", "actions": ["list", "read"]}</code>).</li>
<li>Rotate secrets when sharing with CI jobs—new secrets display once and persist to <code>data/.myfsio.sys/config/iam.json</code>.</li>
<li>Bucket policies layer on top of IAM. Apply Private/Public presets or paste custom JSON; changes reload instantly.</li>
</ol>
</div>
<p class="mb-0 text-muted">All API calls require <code>X-Access-Key</code> and <code>X-Secret-Key</code> headers. The UI stores them in the Flask session after you log in.</p>
</div>
</article>
<article id="console" class="card shadow-sm docs-section">
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-3">
<span class="docs-section-kicker">03</span>
<h2 class="h4 mb-0">Use the console effectively</h2>
</div>
<p class="text-muted">Each workspace models an S3 workflow so you can administer buckets end-to-end.</p>
<div class="docs-pill-list">
<div>
<h3 class="h6 text-uppercase text-muted">Buckets</h3>
<ul>
<li>Create/delete buckets from the overview. Badges reveal IAM-only, public-read, or custom-policy states.</li>
<li>Summary stats show live object counts and total capacity; click through for inventories.</li>
</ul>
</div>
<div>
<h3 class="h6 text-uppercase text-muted">Uploads</h3>
<ul>
<li>Drag and drop folders or files into the upload modal. Objects above 16&nbsp;MB switch to multipart automatically.</li>
<li>Progress rows highlight retries, throughput, and completion even if you close the modal.</li>
</ul>
</div>
<div>
<h3 class="h6 text-uppercase text-muted">Object details</h3>
<ul>
<li>Selecting an object opens the preview card with metadata, inline viewers, presign generator, and version history.</li>
<li>Trigger downloads, deletes, restores, or metadata refreshes without leaving the panel.</li>
</ul>
</div>
<div>
<h3 class="h6 text-uppercase text-muted">Policies &amp; versioning</h3>
<ul>
<li>Toggle versioning (requires write access). Archived-only keys are flagged so you can restore them quickly.</li>
<li>The policy editor saves drafts, ships with presets, and hot-reloads <code>data/.myfsio.sys/config/bucket_policies.json</code>.</li>
</ul>
</div>
</div>
</div>
</article>
<article id="automation" class="card shadow-sm docs-section">
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-3">
<span class="docs-section-kicker">04</span>
<h2 class="h4 mb-0">Automate with CLI &amp; tools</h2>
</div>
<p class="text-muted">Point standard S3 clients at {{ api_base }} and reuse the same IAM credentials.</p>
<div class="mb-4">
<h3 class="h6 text-uppercase text-muted">AWS CLI</h3>
<pre class="mb-3"><code class="language-bash">aws configure set aws_access_key_id &lt;access_key&gt;
aws configure set aws_secret_access_key &lt;secret_key&gt;
aws configure set default.region us-east-1
aws --endpoint-url {{ api_base }} s3 ls
aws --endpoint-url {{ api_base }} s3api create-bucket --bucket demo
aws --endpoint-url {{ api_base }} s3 cp ./sample.txt s3://demo/sample.txt
</code></pre>
</div>
<div class="mb-4">
<h3 class="h6 text-uppercase text-muted">s3cmd</h3>
<pre class="mb-3"><code class="language-bash">cat &gt; ~/.s3cfg-myfsio &lt;&lt;'EOF'
host_base = {{ api_host }}
host_bucket = %(bucket)s.{{ api_host }}
access_key = &lt;access_key&gt;
secret_key = &lt;secret_key&gt;
use_https = False
signature_v2 = False
EOF
s3cmd --config ~/.s3cfg-myfsio ls
s3cmd --config ~/.s3cfg-myfsio put notes.txt s3://demo/notes.txt
</code></pre>
</div>
<div>
<h3 class="h6 text-uppercase text-muted">curl / HTTPie</h3>
<pre class="mb-0"><code class="language-bash">curl {{ api_base }}/ \
-H "X-Access-Key: &lt;access_key&gt;" \
-H "X-Secret-Key: &lt;secret_key&gt;"
curl -X PUT {{ api_base }}/demo/notes.txt \
-H "X-Access-Key: &lt;access_key&gt;" \
-H "X-Secret-Key: &lt;secret_key&gt;" \
--data-binary @notes.txt
curl -X POST {{ api_base }}/presign/demo/notes.txt \
-H "Content-Type: application/json" \
-H "X-Access-Key: &lt;access_key&gt;" \
-H "X-Secret-Key: &lt;secret_key&gt;" \
-d '{"method":"GET", "expires_in": 900}'
</code></pre>
</div>
</div>
</article>
<article id="api" class="card shadow-sm docs-section">
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-3">
<span class="docs-section-kicker">05</span>
<h2 class="h4 mb-0">Key REST endpoints</h2>
</div>
<div class="table-responsive">
<table class="table table-sm table-borderless align-middle docs-table mb-0">
<thead>
<tr>
<th scope="col">Method</th>
<th scope="col">Path</th>
<th scope="col">Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/</code></td>
<td>List buckets accessible to the caller.</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/&lt;bucket&gt;</code></td>
<td>Create a bucket.</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/&lt;bucket&gt;</code></td>
<td>Delete a bucket (must be empty).</td>
</tr>
<tr>
<td>GET</td>
<td><code>/&lt;bucket&gt;</code></td>
<td>List objects (supports <code>prefix</code> / <code>max-keys</code> queries).</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/&lt;bucket&gt;/&lt;key&gt;</code></td>
<td>Upload or overwrite an object; UI helper handles multipart flows.</td>
</tr>
<tr>
<td>GET</td>
<td><code>/&lt;bucket&gt;/&lt;key&gt;</code></td>
<td>Download an object (UI adds <code>?download=1</code> to force attachment).</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/&lt;bucket&gt;/&lt;key&gt;</code></td>
<td>Delete an object.</td>
</tr>
<tr>
<td>GET/PUT/DELETE</td>
<td><code>/bucket-policy/&lt;bucket&gt;</code></td>
<td>Fetch, upsert, or remove a bucket policy.</td>
</tr>
<tr>
<td>POST</td>
<td><code>/presign/&lt;bucket&gt;/&lt;key&gt;</code></td>
<td>Generate SigV4 URLs for GET/PUT/DELETE with custom expiry.</td>
</tr>
</tbody>
</table>
</div>
<p class="small text-muted mt-3 mb-0">All responses include <code>X-Request-Id</code> for tracing. Logs land in <code>logs/api.log</code> and <code>logs/ui.log</code>.</p>
</div>
</article>
<article id="examples" class="card shadow-sm docs-section">
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-3">
<span class="docs-section-kicker">06</span>
<h2 class="h4 mb-0">API Examples</h2>
</div>
<p class="text-muted">Common operations using boto3.</p>
<h5 class="mt-4">Multipart Upload</h5>
<pre><code class="language-python">import boto3
s3 = boto3.client('s3', endpoint_url='{{ api_base }}')
# Initiate
response = s3.create_multipart_upload(Bucket='mybucket', Key='large.bin')
upload_id = response['UploadId']
# Upload parts
parts = []
chunks = [b'chunk1', b'chunk2'] # Example data chunks
for part_number, chunk in enumerate(chunks, start=1):
response = s3.upload_part(
Bucket='mybucket',
Key='large.bin',
PartNumber=part_number,
UploadId=upload_id,
Body=chunk
)
parts.append({'PartNumber': part_number, 'ETag': response['ETag']})
# Complete
s3.complete_multipart_upload(
Bucket='mybucket',
Key='large.bin',
UploadId=upload_id,
MultipartUpload={'Parts': parts}
)</code></pre>
</div>
</article>
<article id="replication" class="card shadow-sm docs-section">
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-3">
<span class="docs-section-kicker">07</span>
<h2 class="h4 mb-0">Site Replication</h2>
</div>
<p class="text-muted">Automatically copy new objects to another MyFSIO instance or S3-compatible service for backup or disaster recovery.</p>
<h3 class="h6 text-uppercase text-muted mt-4">Setup Guide</h3>
<ol class="docs-steps mb-3">
<li>
<strong>Prepare Target:</strong> On the destination server, create a bucket (e.g., <code>backup-bucket</code>) and an IAM user with write permissions.
</li>
<li>
<strong>Connect Source:</strong> On this server, go to <a href="{{ url_for('ui.connections_dashboard') }}">Connections</a> and add the target's API URL and credentials.
</li>
<li>
<strong>Enable Rule:</strong> Go to the source bucket's <strong>Replication</strong> tab, select the connection, and enter the target bucket name.
</li>
</ol>
<div class="alert alert-light border mb-0">
<div class="d-flex gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-terminal text-muted mt-1" viewBox="0 0 16 16">
<path d="M6 9a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3A.5.5 0 0 1 6 9zM3.854 4.146a.5.5 0 1 0-.708.708L4.793 6.5 3.146 8.146a.5.5 0 1 0 .708.708l2-2a.5.5 0 0 0 0-.708l-2-2z"/>
<path d="M2 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2H2zm12 1a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1h12z"/>
</svg>
<div>
<strong>Headless Target Setup?</strong>
<p class="small text-muted mb-0">If your target server has no UI, use the Python API directly to bootstrap credentials. See <code>docs.md</code> in the project root for the <code>setup_target.py</code> script.</p>
</div>
</div>
</div>
</div>
</article>
<article id="troubleshooting" class="card shadow-sm docs-section">
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-3">
<span class="docs-section-kicker">08</span>
<h2 class="h4 mb-0">Troubleshooting &amp; tips</h2>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle docs-table mb-0">
<thead>
<tr>
<th scope="col">Symptom</th>
<th scope="col">Likely cause</th>
<th scope="col">Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td>403 from API despite Public preset</td>
<td>Policy not saved or ARN mismatch</td>
<td>Reapply the preset and confirm <code>arn:aws:s3:::bucket/*</code> matches the bucket name.</td>
</tr>
<tr>
<td>UI shows stale policy/object data</td>
<td>Browser cached prior state</td>
<td>Refresh; the server hot-reloads <code>data/.myfsio.sys/config/bucket_policies.json</code> and storage metadata.</td>
</tr>
<tr>
<td>Presign dialog returns 403</td>
<td>User lacks required <code>read/write/delete</code> action or bucket policy denies</td>
<td>Update IAM inline policies or remove conflicting deny statements.</td>
</tr>
<tr>
<td>Large uploads fail instantly</td>
<td><code>MAX_UPLOAD_SIZE</code> exceeded</td>
<td>Raise the env var or split the object.</td>
</tr>
<tr>
<td>Requests hit the wrong host</td>
<td><code>API_BASE_URL</code> not updated after tunneling/forwarding</td>
<td>Set <code>API_BASE_URL</code> in your shell or <code>.env</code> to match the published host.</td>
</tr>
</tbody>
</table>
</div>
</div>
</article>
</div>
<div class="col-xl-4">
<aside class="card shadow-sm docs-sidebar">
<div class="card-body">
<h3 class="h6 text-uppercase text-muted mb-3">On this page</h3>
<ul class="list-unstyled docs-toc mb-4">
<li><a href="#setup">Set up &amp; run</a></li>
<li><a href="#auth">Authentication &amp; IAM</a></li>
<li><a href="#console">Console tour</a></li>
<li><a href="#automation">Automation / CLI</a></li>
<li><a href="#api">REST endpoints</a></li>
<li><a href="#replication">Site Replication</a></li>
<li><a href="#troubleshooting">Troubleshooting</a></li>
</ul>
<div class="docs-sidebar-callouts">
<div>
<div class="small text-uppercase text-muted">API base</div>
<code class="d-block">{{ api_base }}</code>
</div>
<div>
<div class="small text-uppercase text-muted">Sample user</div>
<code class="d-block">localadmin / localadmin</code>
</div>
<div>
<div class="small text-uppercase text-muted">Logs</div>
<span class="text-muted small">logs/api.log · logs/ui.log</span>
</div>
</div>
<div class="mt-4">
<p class="small text-muted mb-1">Need more automation? Extend <code>app/s3_api.py</code> or wrap <code>run_api.py</code> with gunicorn for production-style deployments.</p>
</div>
</div>
</aside>
</div>
</div>
{% endblock %}

557
templates/iam.html Normal file
View File

@@ -0,0 +1,557 @@
{% extends "base.html" %}
{% block content %}
{% set iam_disabled = 'disabled' if iam_locked else '' %}
<div class="page-header d-flex justify-content-between align-items-center mb-4">
<div>
<p class="text-uppercase text-muted small mb-1">Identity & Access Management</p>
<h1 class="h3 mb-1">IAM Configuration</h1>
</div>
<div class="d-flex gap-2">
{% if not iam_locked %}
<button class="btn btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#configPreview" aria-expanded="false" aria-controls="configPreview">
View Config JSON
</button>
{% endif %}
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createUserModal" {{ iam_disabled }}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-person-plus me-1" viewBox="0 0 16 16">
<path d="M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm.5-5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0Zm-2-6a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM8 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/>
<path d="M8.256 14a4.474 4.474 0 0 1-.229-1.004H3c.001-.246.154-.986.832-1.664C4.484 10.68 5.711 10 8 10c.26 0 .507.009.74.025.226-.341.496-.65.804-.918C9.077 9.038 8.564 9 8 9c-5 0-6 3-6 4s1 1 1 1h5.256Z"/>
</svg>
Create User
</button>
</div>
</div>
{% if iam_locked %}
<div class="alert alert-warning" role="alert">
<div class="fw-semibold mb-1">Administrator permissions required</div>
<p class="mb-0">You need the <code>iam:list_users</code> action to edit users or policies. {{ locked_reason or "Sign in with an admin identity to continue." }}</p>
</div>
{% endif %}
{% if disclosed_secret %}
<div class="alert alert-info border-0 shadow-sm mb-4" role="alert">
<div class="d-flex align-items-start gap-2 mb-2">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-key flex-shrink-0 mt-1" viewBox="0 0 16 16">
<path d="M0 8a4 4 0 0 1 7.465-2H14a.5.5 0 0 1 .354.146l1.5 1.5a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0L13 9.207l-.646.647a.5.5 0 0 1-.708 0L11 9.207l-.646.647a.5.5 0 0 1-.708 0L9 9.207l-.646.647A.5.5 0 0 1 8 10h-.535A4 4 0 0 1 0 8zm4-3a3 3 0 1 0 2.712 4.285A.5.5 0 0 1 7.163 9h.63l.853-.854a.5.5 0 0 1 .708 0l.646.647.646-.647a.5.5 0 0 1 .708 0l.646.647.646-.647a.5.5 0 0 1 .708 0l.646.647.793-.793-1-1h-6.63a.5.5 0 0 1-.451-.285A3 3 0 0 0 4 5z"/>
<path d="M4 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>
</svg>
<div class="flex-grow-1">
<div class="fw-semibold">
{% if disclosed_secret.operation == 'rotate' %}
Secret rotated for <code>{{ disclosed_secret.access_key }}</code>
{% else %}
New user created: <code>{{ disclosed_secret.access_key }}</code>
{% endif %}
</div>
<p class="mb-2 small">⚠️ This secret is only shown once. Copy it now and store it securely.</p>
</div>
</div>
<div class="input-group">
<span class="input-group-text"><strong>Secret key</strong></span>
<input class="form-control font-monospace" type="text" value="{{ disclosed_secret.secret_key }}" readonly id="disclosedSecretValue" />
<button class="btn btn-outline-primary" type="button" data-secret-copy>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
</svg>
Copy
</button>
</div>
</div>
{% endif %}
{% if not iam_locked %}
<div class="collapse mb-4" id="configPreview">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<span class="fw-semibold">Configuration Preview</span>
<span class="badge text-bg-secondary">{{ config_summary.user_count }} users</span>
</div>
<div class="card-body">
<div class="position-relative">
<pre class="policy-preview mb-0" id="iamConfigPreview">{{ config_document }}</pre>
<button class="btn btn-outline-light btn-sm config-copy" type="button" data-copy-target="iamConfigPreview">Copy JSON</button>
</div>
<p class="text-muted small mt-2 mb-0">Secrets are masked above. Access <code>{{ config_summary.path }}</code> directly to view full credentials.</p>
</div>
</div>
</div>
{% endif %}
<div class="card shadow-sm">
<div class="card-header bg-body d-flex justify-content-between align-items-center">
<span class="fw-semibold">Users</span>
{% if iam_locked %}<span class="badge text-bg-warning">View only</span>{% endif %}
</div>
{% if iam_locked %}
<div class="card-body">
<p class="text-muted mb-0">Sign in with an administrator to list or edit IAM users.</p>
</div>
{% else %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th scope="col">Access Key</th>
<th scope="col">Display Name</th>
<th scope="col">Policies</th>
<th scope="col" class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td class="font-monospace">{{ user.access_key }}</td>
<td>{{ user.display_name }}</td>
<td>
{% for policy in user.policies %}
<span class="badge text-bg-light border text-dark mb-1">
{{ policy.bucket }}
{% if '*' in policy.actions %}
<span class="text-muted">(*)</span>
{% else %}
<span class="text-muted">({{ policy.actions|length }})</span>
{% endif %}
</span>
{% endfor %}
</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-primary" type="button" data-rotate-user="{{ user.access_key }}" title="Rotate Secret">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-arrow-repeat" viewBox="0 0 16 16">
<path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z"/>
<path fill-rule="evenodd" d="M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"/>
</svg>
</button>
<button class="btn btn-outline-secondary" type="button" data-edit-user="{{ user.access_key }}" data-display-name="{{ user.display_name }}" title="Edit User">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-pencil" viewBox="0 0 16 16">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.378.378-.106 5-5-.378-.378-5 5z"/>
</svg>
</button>
<button class="btn btn-outline-secondary" type="button" data-policy-editor data-access-key="{{ user.access_key }}" title="Edit Policies">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-pencil-square" viewBox="0 0 16 16">
<path d="M15.502 1.94a.5.5 0 0 1 0 .706L14.459 3.69l-2-2L13.502.646a.5.5 0 0 1 .707 0l1.293 1.293zm-1.75 2.456-2-2L4.939 9.21a.5.5 0 0 0-.121.196l-.805 2.414a.25.25 0 0 0 .316.316l2.414-.805a.5.5 0 0 0 .196-.12l6.813-6.814z"/>
<path fill-rule="evenodd" d="M1 13.5A1.5 1.5 0 0 0 2.5 15h11a1.5 1.5 0 0 0 1.5-1.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-11a.5.5 0 0 1 .5-.5H9a.5.5 0 0 0 0-1H2.5A1.5 1.5 0 0 0 1 2.5v11z"/>
</svg>
</button>
<button class="btn btn-outline-danger" type="button" data-delete-user="{{ user.access_key }}" title="Delete User">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">
<path d="M5.5 5.5a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0v-6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0v-6a.5.5 0 0 1 .5-.5zm3 .5v6a.5.5 0 0 1-1 0v-6a.5.5 0 0 1 1 0z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
</button>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center text-muted py-4">No IAM users defined.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
<!-- Create User Modal -->
<div class="modal fade" id="createUserModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5">Create IAM User</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="{{ url_for('ui.create_iam_user') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Display Name</label>
<input class="form-control" type="text" name="display_name" placeholder="Analytics Team" required />
</div>
<div class="mb-3">
<label class="form-label">Initial Policies (JSON)</label>
<textarea class="form-control font-monospace" name="policies" rows="6" spellcheck="false" placeholder='[
{"bucket": "*", "actions": ["list", "read"]}
]'></textarea>
<div class="form-text">Leave blank to grant full control (for bootstrap admins only).</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-primary" type="submit">Create User</button>
</div>
</form>
</div>
</div>
</div>
<!-- Policy Editor Modal -->
<div class="modal fade" id="policyEditorModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5">Edit Policies: <span id="policyEditorUserLabel" class="font-monospace"></span></h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form
id="policyEditorForm"
method="post"
data-action-template="{{ url_for('ui.update_iam_policies', access_key='ACCESS_KEY_PLACEHOLDER') }}"
class="d-flex flex-column gap-3"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" id="policyEditorUser" name="access_key" />
<div>
<label class="form-label">Inline Policies (JSON array)</label>
<textarea class="form-control font-monospace" id="policyEditorDocument" name="policies" rows="12" spellcheck="false"></textarea>
<div class="form-text">Use standard MyFSIO policy format. Validation happens server-side.</div>
</div>
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-outline-secondary btn-sm" type="button" data-policy-template="full">Full Control</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-policy-template="readonly">Read-Only</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-policy-template="writer">Read + Write</button>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-primary" type="submit" form="policyEditorForm">Save Policies</button>
</div>
</div>
</div>
</div>
<!-- Edit User Modal -->
<div class="modal fade" id="editUserModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5">Edit User</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" id="editUserForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Display Name</label>
<input class="form-control" type="text" name="display_name" id="editUserDisplayName" required />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-primary" type="submit">Save Changes</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete User Modal -->
<div class="modal fade" id="deleteUserModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5">Delete User</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete user <strong id="deleteUserLabel"></strong>?</p>
<div id="deleteSelfWarning" class="alert alert-danger d-none">
<strong>Warning:</strong> You are deleting your own account. You will be logged out immediately and will lose access to this session.
</div>
<p class="text-danger mb-0">This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<form method="post" id="deleteUserForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<button class="btn btn-danger" type="submit">Delete User</button>
</form>
</div>
</div>
</div>
</div>
<!-- Rotate Secret Modal -->
<div class="modal fade" id="rotateSecretModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5">Rotate Secret Key</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="rotateSecretConfirm">
<p>Are you sure you want to rotate the secret key for <strong id="rotateUserLabel"></strong>?</p>
<div id="rotateSelfWarning" class="alert alert-warning d-none">
<strong>Warning:</strong> You are rotating your own secret key. You will need to sign in again with the new key.
</div>
<div class="alert alert-warning mb-0">
The old secret key will stop working immediately. Any applications using it must be updated.
</div>
</div>
<div class="modal-body d-none" id="rotateSecretResult">
<p class="mb-2">Secret rotated successfully!</p>
<div class="input-group mb-3">
<input type="text" class="form-control font-monospace" id="newSecretKey" readonly>
<button class="btn btn-outline-primary" type="button" id="copyNewSecret">Copy</button>
</div>
<p class="small text-muted mb-0">Copy this now. It will not be shown again.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="rotateCancelBtn">Cancel</button>
<button type="button" class="btn btn-primary" id="confirmRotateBtn">Rotate Key</button>
<button type="button" class="btn btn-primary d-none" data-bs-dismiss="modal" id="rotateDoneBtn">Done</button>
</div>
</div>
</div>
</div>
<script id="iamUsersJson" type="application/json">{{ users | tojson }}</script>
{% endblock %}
{% block extra_scripts %}
{{ super() }}
<script>
(function () {
const currentUserKey = {{ principal.access_key | tojson }};
const configCopyButtons = document.querySelectorAll('.config-copy');
configCopyButtons.forEach((button) => {
button.addEventListener('click', async () => {
const targetId = button.dataset.copyTarget;
const target = document.getElementById(targetId);
if (!target) return;
const text = target.innerText;
try {
await navigator.clipboard.writeText(text);
button.textContent = 'Copied!';
setTimeout(() => {
button.textContent = 'Copy JSON';
}, 1500);
} catch (err) {
console.error('Unable to copy IAM config', err);
}
});
});
const secretCopyButton = document.querySelector('[data-secret-copy]');
if (secretCopyButton) {
secretCopyButton.addEventListener('click', async () => {
const secretInput = document.getElementById('disclosedSecretValue');
if (!secretInput) return;
try {
await navigator.clipboard.writeText(secretInput.value);
secretCopyButton.textContent = 'Copied!';
setTimeout(() => {
secretCopyButton.textContent = 'Copy';
}, 1500);
} catch (err) {
console.error('Unable to copy IAM secret', err);
}
});
}
const iamUsersData = document.getElementById('iamUsersJson');
const users = iamUsersData ? JSON.parse(iamUsersData.textContent || '[]') : [];
// Policy Editor Logic
const policyModalEl = document.getElementById('policyEditorModal');
const policyModal = new bootstrap.Modal(policyModalEl);
const userLabelEl = document.getElementById('policyEditorUserLabel');
const userInputEl = document.getElementById('policyEditorUser');
const textareaEl = document.getElementById('policyEditorDocument');
const formEl = document.getElementById('policyEditorForm');
const templateButtons = document.querySelectorAll('[data-policy-template]');
const iamLocked = {{ iam_locked | tojson }};
if (iamLocked) return;
const userPolicies = (accessKey) => {
const target = users.find((user) => user.access_key === accessKey);
return target ? JSON.stringify(target.policies, null, 2) : '';
};
const applyTemplate = (name) => {
const templates = {
full: [
{
bucket: '*',
actions: ['list', 'read', 'write', 'delete', 'share', 'policy', 'iam:list_users', 'iam:*'],
},
],
readonly: [
{
bucket: '*',
actions: ['list', 'read'],
},
],
writer: [
{
bucket: '*',
actions: ['list', 'read', 'write'],
},
],
};
if (templates[name]) {
textareaEl.value = JSON.stringify(templates[name], null, 2);
}
};
templateButtons.forEach((button) => {
button.addEventListener('click', () => applyTemplate(button.dataset.policyTemplate));
});
formEl?.addEventListener('submit', (event) => {
const key = userInputEl.value;
if (!key) {
event.preventDefault();
return;
}
const template = formEl.dataset.actionTemplate;
formEl.action = template.replace('ACCESS_KEY_PLACEHOLDER', key);
});
document.querySelectorAll('[data-policy-editor]').forEach((button) => {
button.addEventListener('click', () => {
const key = button.getAttribute('data-access-key');
if (!key) return;
userLabelEl.textContent = key;
userInputEl.value = key;
textareaEl.value = userPolicies(key);
policyModal.show();
});
});
// Edit User Logic
const editUserModal = new bootstrap.Modal(document.getElementById('editUserModal'));
const editUserForm = document.getElementById('editUserForm');
const editUserDisplayName = document.getElementById('editUserDisplayName');
document.querySelectorAll('[data-edit-user]').forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.dataset.editUser;
const name = btn.dataset.displayName;
editUserDisplayName.value = name;
editUserForm.action = "{{ url_for('ui.update_iam_user', access_key='ACCESS_KEY') }}".replace('ACCESS_KEY', key);
editUserModal.show();
});
});
// Delete User Logic
const deleteUserModal = new bootstrap.Modal(document.getElementById('deleteUserModal'));
const deleteUserForm = document.getElementById('deleteUserForm');
const deleteUserLabel = document.getElementById('deleteUserLabel');
const deleteSelfWarning = document.getElementById('deleteSelfWarning');
document.querySelectorAll('[data-delete-user]').forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.dataset.deleteUser;
deleteUserLabel.textContent = key;
deleteUserForm.action = "{{ url_for('ui.delete_iam_user', access_key='ACCESS_KEY') }}".replace('ACCESS_KEY', key);
if (key === currentUserKey) {
deleteSelfWarning.classList.remove('d-none');
} else {
deleteSelfWarning.classList.add('d-none');
}
deleteUserModal.show();
});
});
// Rotate Secret Logic
const rotateSecretModal = new bootstrap.Modal(document.getElementById('rotateSecretModal'));
const rotateUserLabel = document.getElementById('rotateUserLabel');
const confirmRotateBtn = document.getElementById('confirmRotateBtn');
const rotateCancelBtn = document.getElementById('rotateCancelBtn');
const rotateDoneBtn = document.getElementById('rotateDoneBtn');
const rotateSecretConfirm = document.getElementById('rotateSecretConfirm');
const rotateSecretResult = document.getElementById('rotateSecretResult');
const newSecretKeyInput = document.getElementById('newSecretKey');
const copyNewSecretBtn = document.getElementById('copyNewSecret');
const rotateSelfWarning = document.getElementById('rotateSelfWarning');
let currentRotateKey = null;
document.querySelectorAll('[data-rotate-user]').forEach(btn => {
btn.addEventListener('click', () => {
currentRotateKey = btn.dataset.rotateUser;
rotateUserLabel.textContent = currentRotateKey;
if (currentRotateKey === currentUserKey) {
rotateSelfWarning.classList.remove('d-none');
} else {
rotateSelfWarning.classList.add('d-none');
}
// Reset Modal State
rotateSecretConfirm.classList.remove('d-none');
rotateSecretResult.classList.add('d-none');
confirmRotateBtn.classList.remove('d-none');
rotateCancelBtn.classList.remove('d-none');
rotateDoneBtn.classList.add('d-none');
rotateSecretModal.show();
});
});
confirmRotateBtn.addEventListener('click', async () => {
if (!currentRotateKey) return;
confirmRotateBtn.disabled = true;
confirmRotateBtn.textContent = "Rotating...";
try {
const url = "{{ url_for('ui.rotate_iam_secret', access_key='ACCESS_KEY') }}".replace('ACCESS_KEY', currentRotateKey);
const response = await fetch(url, {
method: 'POST',
headers: {
'Accept': 'application/json',
'X-CSRFToken': "{{ csrf_token() }}"
}
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to rotate secret');
}
const data = await response.json();
newSecretKeyInput.value = data.secret_key;
// Show Result
rotateSecretConfirm.classList.add('d-none');
rotateSecretResult.classList.remove('d-none');
confirmRotateBtn.classList.add('d-none');
rotateCancelBtn.classList.add('d-none');
rotateDoneBtn.classList.remove('d-none');
} catch (err) {
alert(err.message);
rotateSecretModal.hide();
} finally {
confirmRotateBtn.disabled = false;
confirmRotateBtn.textContent = "Rotate Key";
}
});
copyNewSecretBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(newSecretKeyInput.value);
copyNewSecretBtn.textContent = 'Copied!';
setTimeout(() => copyNewSecretBtn.textContent = 'Copy', 1500);
} catch (err) {
console.error('Failed to copy', err);
}
});
rotateDoneBtn.addEventListener('click', () => {
window.location.reload();
});
})();
</script>
{% endblock %}

29
templates/login.html Normal file
View File

@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block content %}
<div class="row align-items-center mt-5 g-4">
<div class="col-lg-6">
<h1 class="display-6 mb-3">Welcome to <span class="text-primary">MyFSIO</span></h1>
<p class="lead text-muted">A developer-friendly object storage solution for prototyping and local development.</p>
<p class="text-muted mb-0">Need help getting started? Review the project README and docs for bootstrap credentials, IAM walkthroughs, and bucket policy samples.</p>
</div>
<div class="col-lg-5 ms-auto">
<div class="card shadow-sm">
<div class="card-body">
<h2 class="h4 mb-3">Sign in</h2>
<form method="post" action="{{ url_for('ui.login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="mb-3">
<label class="form-label">Access key</label>
<input class="form-control" type="text" name="access_key" required autofocus />
</div>
<div class="mb-4">
<label class="form-label">Secret key</label>
<input class="form-control" type="password" name="secret_key" required />
</div>
<button class="btn btn-primary w-100" type="submit">Continue</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}