feat: initial commit

This commit is contained in:
2026-05-20 23:26:11 +01:00
commit 5b18c7c975
28 changed files with 5141 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
FROM mcr.microsoft.com/devcontainers/python:3.14
WORKDIR /workspace
CMD ["sleep", "infinity"]
+18
View File
@@ -0,0 +1,18 @@
{
"name": "Flask Dev Container",
"build": {
"dockerfile": "Dockerfile"
},
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"vivaxy.vscode-conventional-commits",
"esbenp.prettier-vscode"
]
}
},
"postCreateCommand": "pip install -r requirements.txt",
"forwardPorts": [5000],
"remoteUser": "vscode"
}
+7
View File
@@ -0,0 +1,7 @@
.devcontainer
.git
.gitea
__pycache__
*.pyc
.env
frontend/node_modules
+23
View File
@@ -0,0 +1,23 @@
# Flask
SECRET_KEY=change-me
SESSION_DAYS=14
SESSION_COOKIE_SECURE=false
WEBAPP_USERNAME=admin
WEBAPP_PASSWORD=change-me
DB_ENCRYPTION_KEY=change-me
# MariaDB
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_DATABASE=s3_web
MYSQL_USER=root
MYSQL_PASSWORD=
MYSQL_POOL_SIZE=5
# Optional defaults for S3
S3_ENDPOINT_URL=
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_BUCKET_NAME=
S3_REGION=
S3_FORCE_PATH_STYLE=false
+46
View File
@@ -0,0 +1,46 @@
name: CI
on:
workflow_dispatch:
jobs:
build-and-push:
name: Build and Push
runs-on: build-htz-01
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build and push Docker image
run: |
docker build -t cr.jdbnet.co.uk/public/s3-client:dev .
docker push cr.jdbnet.co.uk/public/s3-client:dev
sonarqube:
name: SonarQube
runs-on: build-htz-01
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create Valid Project Key
id: sonar_setup
run: |
CLEAN_KEY=$(echo "${{ gitea.repository }}" | tr '/' ':')
echo "key=$CLEAN_KEY" >> $GITHUB_OUTPUT
- name: SonarQube Scan
uses: sonarsource/sonarqube-scan-action@master
continue-on-error: true
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
with:
args: >
-Dsonar.projectKey=${{ steps.sonar_setup.outputs.key }}
-Dsonar.projectName=${{ gitea.repository }}
-Dsonar.qualitygate.wait=true
+46
View File
@@ -0,0 +1,46 @@
name: Release
on:
pull_request:
branches:
- main
types: [closed]
jobs:
release:
if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'v')
runs-on: build-htz-01
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract Version
id: get_version
run: echo "VERSION=${{ github.head_ref }}" >> $GITHUB_OUTPUT
# - name: Generate Changelog
# id: changelog
# uses: https://github.com/metcalfc/changelog-generator@v4.6.2
# with:
# myToken: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
run: |
VERSION=${{ steps.get_version.outputs.VERSION }}
docker build -t cr.jdbnet.co.uk/public/s3-client:$VERSION \
-t cr.jdbnet.co.uk/public/s3-client:latest \
--build-arg VERSION=$VERSION \
.
docker push cr.jdbnet.co.uk/public/s3-client:$VERSION
docker push cr.jdbnet.co.uk/public/s3-client:latest
- name: Create Gitea Release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
tag_name: ${{ steps.get_version.outputs.VERSION }}
name: ${{ steps.get_version.outputs.VERSION }}
body: ${{ steps.changelog.outputs.changelog }}
draft: false
prerelease: false
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
.env
frontend/node_modules/
static/dist/
+19
View File
@@ -0,0 +1,19 @@
FROM node:22-bookworm-slim AS frontend
WORKDIR /build
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM python:3.14-slim
LABEL org.opencontainers.image.vendor="JDB-NET"
WORKDIR /app
ARG VERSION=unknown
ENV VERSION=${VERSION}
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app
COPY --from=frontend /static/dist /app/static/dist
ENV GEVENT_MONKEY_PATCH=1
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "1", "app:app", "--log-level", "warning"]
+39
View File
@@ -0,0 +1,39 @@
<div align="center">
<img src="https://assets.jdbnet.co.uk/projects/s3.png" alt="JDB-NET" width="200" />
# S3 Client
A Flask + Vue app for managing S3 and S3-compatible storage with credentials stored in MariaDB.
</div>
## Features
- Encrypted credential storage in MariaDB
- Folder-based organisation of S3 accounts
- Bucket discovery and object browser
- Upload, download, rename, delete, and folder creation for objects
- Vue + Tailwind UI
## Docker Compose
```yaml
services:
app:
image: cr.jdbnet.co.uk/public/s3-client:latest
ports:
- "5000:5000"
environment:
SECRET_KEY: "<YOUR_SECRETKEY>"
SESSION_DAYS: "14"
SESSION_COOKIE_SECURE: "false" # True if using HTTPS
WEBAPP_USERNAME: "<YOUR_USERNAME>"
WEBAPP_PASSWORD: "<YOUR_PASSWORD>"
DB_ENCRYPTION_KEY: "<YOUR_ENCRYPTION_KEY>"
MYSQL_HOST: "<YOUR_MYSQL_HOST>"
MYSQL_PORT: "<YOUR_MYSQL_PORT>"
MYSQL_DATABASE: "<YOUR_MYSQL_DB>"
MYSQL_USER: "<YOUR_MYSQL_USER>"
MYSQL_PASSWORD: "<YOUR_MYSQL_PASSWORD>"
restart: unless-stopped
```
+877
View File
@@ -0,0 +1,877 @@
"""
S3 web client - Flask backend with MariaDB credentials and S3 operations.
"""
from __future__ import annotations
import hmac
import logging
import os
import posixpath
from contextlib import contextmanager
from datetime import timedelta
from typing import Any, Iterable
import boto3
from botocore.config import Config
import mysql.connector
from mysql.connector import pooling
from dotenv import load_dotenv
from flask import Flask, Response, abort, jsonify, request, send_file, send_from_directory, session
from werkzeug.utils import safe_join, secure_filename
load_dotenv()
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
log = logging.getLogger("s3_web")
app = Flask(__name__, static_folder=None)
app.secret_key = os.getenv("SECRET_KEY", "dev-change-me")
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(
days=int(os.getenv("SESSION_DAYS", "14"))
)
app.config["VERSION"] = os.getenv("VERSION", "unknown")
if os.getenv("SESSION_COOKIE_SECURE", "").lower() in ("1", "true", "yes"):
app.config["SESSION_COOKIE_SECURE"] = True
_db_pool: pooling.MySQLConnectionPool | None = None
def get_pool() -> pooling.MySQLConnectionPool:
global _db_pool
if _db_pool is None:
_db_pool = pooling.MySQLConnectionPool(
pool_name="s3_web_pool",
pool_size=int(os.getenv("MYSQL_POOL_SIZE", "5")),
host=os.getenv("MYSQL_HOST", "127.0.0.1"),
port=int(os.getenv("MYSQL_PORT", "3306")),
user=os.getenv("MYSQL_USER", "root"),
password=os.getenv("MYSQL_PASSWORD", ""),
database=os.getenv("MYSQL_DATABASE", "s3_web"),
)
return _db_pool
@contextmanager
def db_cursor(dict_cursor: bool = True):
pool = get_pool()
conn = pool.get_connection()
try:
cur = conn.cursor(dictionary=dict_cursor)
yield conn, cur
conn.commit()
except Exception:
conn.rollback()
raise
finally:
cur.close()
conn.close()
def _aes_key() -> str:
key = os.getenv("DB_ENCRYPTION_KEY") or os.getenv("CREDENTIALS_ENCRYPTION_KEY")
if not key:
raise RuntimeError("DB_ENCRYPTION_KEY is not set")
return key
def init_db() -> None:
ddl = """
CREATE TABLE IF NOT EXISTS s3_folders (
id INT AUTO_INCREMENT PRIMARY KEY,
parent_id INT NULL,
label VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_s3_folder_parent FOREIGN KEY (parent_id)
REFERENCES s3_folders(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS s3_accounts (
id INT AUTO_INCREMENT PRIMARY KEY,
folder_id INT NULL,
label VARCHAR(255) NOT NULL,
endpoint_url VARCHAR(1024) NULL,
region VARCHAR(255) NULL,
force_path_style TINYINT(1) NOT NULL DEFAULT 0,
access_key_enc VARBINARY(512) NOT NULL,
secret_key_enc VARBINARY(512) NOT NULL,
session_token_enc VARBINARY(1024) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_s3_account_folder FOREIGN KEY (folder_id)
REFERENCES s3_folders(id) ON DELETE SET NULL
);
"""
with db_cursor() as (_, cur):
for stmt in ddl.split(";"):
s = stmt.strip()
if s:
cur.execute(s)
def _like_escape(s: str) -> str:
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _folder_subtree_ids(cur, root_id: int) -> list[int]:
cur.execute(
"""
WITH RECURSIVE sub AS (
SELECT id FROM s3_folders WHERE id = %s
UNION ALL
SELECT f.id FROM s3_folders f INNER JOIN sub ON f.parent_id = sub.id
)
SELECT id FROM sub
""",
(root_id,),
)
return [r["id"] for r in cur.fetchall()]
def _folder_breadcrumb_rows(cur, folder_id: int) -> list[dict[str, Any]]:
cur.execute(
"""
WITH RECURSIVE up AS (
SELECT id, parent_id, label FROM s3_folders WHERE id = %s
UNION ALL
SELECT f.id, f.parent_id, f.label
FROM s3_folders f INNER JOIN up ON f.id = up.parent_id
)
SELECT id, label FROM up
""",
(folder_id,),
)
rows = cur.fetchall()
return list(reversed(rows))
def _web_login_ok(username: str, password: str) -> bool:
u = os.getenv("WEBAPP_USERNAME", "")
expected = os.getenv("WEBAPP_PASSWORD", "")
if not u or not expected or username != u:
return False
pa = password.encode("utf-8")
pb = expected.encode("utf-8")
if len(pa) != len(pb):
return False
return hmac.compare_digest(pa, pb)
def require_login(fn):
def wrapped(*args, **kwargs):
if not session.get("logged_in"):
return jsonify({"error": "unauthorized"}), 401
return fn(*args, **kwargs)
wrapped.__name__ = fn.__name__
return wrapped
def _decode_db_value(v: Any) -> str | None:
if v is None:
return None
if isinstance(v, (bytes, bytearray)):
return v.decode("utf-8", errors="replace")
return str(v)
def _load_account_credentials(account_id: int) -> dict[str, Any] | None:
key = _aes_key()
with db_cursor() as (_, cur):
cur.execute(
"""
SELECT id, label, endpoint_url, region, force_path_style,
AES_DECRYPT(access_key_enc, %s) AS access_key,
AES_DECRYPT(secret_key_enc, %s) AS secret_key,
AES_DECRYPT(session_token_enc, %s) AS session_token
FROM s3_accounts
WHERE id = %s
""",
(key, key, key, account_id),
)
row = cur.fetchone()
if not row:
return None
row["access_key"] = _decode_db_value(row.get("access_key"))
row["secret_key"] = _decode_db_value(row.get("secret_key"))
row["session_token"] = _decode_db_value(row.get("session_token"))
row["force_path_style"] = bool(row.get("force_path_style"))
return row
def _s3_client(account: dict[str, Any]):
endpoint_url = (account.get("endpoint_url") or "").strip() or None
region = (account.get("region") or "").strip() or None
addressing_style = "path" if account.get("force_path_style") else "virtual"
return boto3.client(
"s3",
endpoint_url=endpoint_url,
region_name=region,
aws_access_key_id=account.get("access_key"),
aws_secret_access_key=account.get("secret_key"),
aws_session_token=account.get("session_token") or None,
config=Config(s3={"addressing_style": addressing_style}),
)
def _normalize_path(path: str) -> str:
if not path or path == "/":
return ""
p = path.strip("/")
if not p:
return ""
if not p.endswith("/"):
p += "/"
return p
def _join_key(prefix: str, name: str) -> str:
if not prefix:
return name
return posixpath.join(prefix, name)
def _paginate_list_objects(client, bucket: str, prefix: str, delimiter: str = "/") -> dict[str, Any]:
params = {
"Bucket": bucket,
"Prefix": prefix,
"Delimiter": delimiter,
}
resp = client.list_objects_v2(**params)
return resp
@app.route("/api/login", methods=["POST"])
def api_login():
body = request.get_json(silent=True) or {}
username = (body.get("username") or "").strip()
password = body.get("password") or ""
if _web_login_ok(username, password):
session["logged_in"] = True
session.permanent = bool(os.getenv("SESSION_PERMANENT", "1").lower() in ("1", "true", "yes"))
return jsonify({"ok": True})
return jsonify({"ok": False, "error": "invalid credentials"}), 401
@app.route("/api/logout", methods=["POST"])
def api_logout():
session.pop("logged_in", None)
return jsonify({"ok": True})
@app.route("/api/me", methods=["GET"])
def api_me():
version = app.config.get("VERSION", "unknown")
if session.get("logged_in"):
return jsonify({"logged_in": True, "app_version": version})
return jsonify({"logged_in": False, "app_version": version})
@app.route("/api/folders", methods=["GET"])
@require_login
def list_all_folders():
with db_cursor() as (_, cur):
cur.execute(
"SELECT id, label, parent_id FROM s3_folders ORDER BY label"
)
rows = cur.fetchall()
return jsonify({"items": rows})
@app.route("/api/folders", methods=["POST"])
@require_login
def create_folder():
body = request.get_json(silent=True) or {}
label = (body.get("label") or "").strip()
if not label:
return jsonify({"error": "label required"}), 400
pid = body.get("parent_id")
parent_id = int(pid) if pid is not None and pid != "" else None
if parent_id is not None:
with db_cursor() as (_, cur):
cur.execute("SELECT id FROM s3_folders WHERE id = %s", (parent_id,))
if not cur.fetchone():
return jsonify({"error": "parent not found"}), 400
with db_cursor() as (_, cur):
cur.execute(
"INSERT INTO s3_folders (label, parent_id) VALUES (%s, %s)",
(label, parent_id),
)
fid = cur.lastrowid
return jsonify({"id": fid}), 201
@app.route("/api/folders/<int:fid>", methods=["PATCH"])
@require_login
def update_folder(fid: int):
body = request.get_json(silent=True) or {}
with db_cursor() as (_, cur):
cur.execute("SELECT id, parent_id, label FROM s3_folders WHERE id = %s", (fid,))
row = cur.fetchone()
if not row:
return jsonify({"error": "not found"}), 404
sets = []
args: list[Any] = []
if "label" in body:
sets.append("label = %s")
args.append(str(body["label"]).strip())
if "parent_id" in body:
p = body["parent_id"]
new_parent = int(p) if p is not None and p != "" else None
if new_parent == fid:
return jsonify({"error": "cannot set parent to self"}), 400
with db_cursor() as (_, cur):
if new_parent is not None:
cur.execute("SELECT id FROM s3_folders WHERE id = %s", (new_parent,))
if not cur.fetchone():
return jsonify({"error": "parent not found"}), 400
sub = _folder_subtree_ids(cur, fid)
if new_parent is not None and new_parent in sub:
return jsonify({"error": "cannot move folder into its descendant"}), 400
sets.append("parent_id = %s")
args.append(new_parent)
if not sets:
return jsonify({"ok": True})
args.append(fid)
with db_cursor() as (_, cur):
cur.execute(
f"UPDATE s3_folders SET {', '.join(sets)} WHERE id = %s",
tuple(args),
)
return jsonify({"ok": True})
@app.route("/api/folders/<int:fid>", methods=["DELETE"])
@require_login
def delete_folder(fid: int):
with db_cursor() as (_, cur):
cur.execute("DELETE FROM s3_folders WHERE id = %s", (fid,))
if cur.rowcount == 0:
return jsonify({"error": "not found"}), 404
return jsonify({"ok": True})
def _account_select_sql(extra_where: str = "") -> str:
return f"""
SELECT a.id, a.folder_id, a.label, a.endpoint_url, a.region,
a.force_path_style, a.created_at, a.updated_at,
pf.label AS folder_label
FROM s3_accounts a
LEFT JOIN s3_folders pf ON pf.id = a.folder_id
{extra_where}
"""
@app.route("/api/browse", methods=["GET"])
@require_login
def api_browse():
raw_fid = request.args.get("folder_id")
if raw_fid in (None, "", "root"):
folder_id = None
else:
try:
folder_id = int(raw_fid)
except (TypeError, ValueError):
return jsonify({"error": "invalid folder_id"}), 400
q = (request.args.get("q") or "").strip()
esc = _like_escape(q) if q else ""
pat = f"%{esc}%" if q else ""
with db_cursor() as (_, cur):
breadcrumb: list[dict[str, Any]] = []
if folder_id is not None:
cur.execute("SELECT id FROM s3_folders WHERE id = %s", (folder_id,))
if not cur.fetchone():
return jsonify({"error": "folder not found"}), 404
breadcrumb = _folder_breadcrumb_rows(cur, folder_id)
if q:
if folder_id is None:
cur.execute(
_account_select_sql(
"WHERE (a.label LIKE %s ESCAPE '\\\\' OR a.endpoint_url LIKE %s ESCAPE '\\\\')"
)
+ " ORDER BY a.label",
(pat, pat),
)
accounts = cur.fetchall()
else:
ids = _folder_subtree_ids(cur, folder_id)
if not ids:
accounts = []
else:
ph = ",".join(["%s"] * len(ids))
cur.execute(
_account_select_sql(
f"WHERE a.folder_id IN ({ph}) AND "
"(a.label LIKE %s ESCAPE '\\\\' OR a.endpoint_url LIKE %s ESCAPE '\\\\')"
)
+ " ORDER BY a.label",
(*ids, pat, pat),
)
accounts = cur.fetchall()
return jsonify(
{
"breadcrumb": breadcrumb,
"folders": [],
"accounts": accounts,
"search_active": True,
}
)
if folder_id is None:
cur.execute(
"SELECT id, label, parent_id FROM s3_folders WHERE parent_id IS NULL ORDER BY label"
)
folders = cur.fetchall()
cur.execute(
_account_select_sql("WHERE a.folder_id IS NULL") + " ORDER BY a.label"
)
else:
cur.execute(
"SELECT id, label, parent_id FROM s3_folders WHERE parent_id = %s ORDER BY label",
(folder_id,),
)
folders = cur.fetchall()
cur.execute(
_account_select_sql("WHERE a.folder_id = %s") + " ORDER BY a.label",
(folder_id,),
)
accounts = cur.fetchall()
return jsonify(
{
"breadcrumb": breadcrumb,
"folders": folders,
"accounts": accounts,
"search_active": False,
}
)
@app.route("/api/accounts", methods=["GET"])
@require_login
def list_accounts():
with db_cursor() as (_, cur):
cur.execute(_account_select_sql("") + " ORDER BY a.label")
rows = cur.fetchall()
return jsonify({"items": rows})
@app.route("/api/accounts", methods=["POST"])
@require_login
def create_account():
body = request.get_json(silent=True) or {}
label = (body.get("label") or "").strip()
if not label:
return jsonify({"error": "label required"}), 400
endpoint_url = (body.get("endpoint_url") or "").strip() or None
region = (body.get("region") or "").strip() or None
force_path_style = bool(body.get("force_path_style"))
access_key = (body.get("access_key") or "").strip()
secret_key = (body.get("secret_key") or "").strip()
session_token = (body.get("session_token") or "").strip()
if not access_key or not secret_key:
return jsonify({"error": "access_key and secret_key required"}), 400
fid = body.get("folder_id")
folder_id = int(fid) if fid is not None and fid != "" else None
if folder_id is not None:
with db_cursor() as (_, cur):
cur.execute("SELECT id FROM s3_folders WHERE id = %s", (folder_id,))
if not cur.fetchone():
return jsonify({"error": "folder not found"}), 400
key = _aes_key()
with db_cursor() as (_, cur):
cur.execute(
"""
INSERT INTO s3_accounts (
folder_id, label, endpoint_url, region, force_path_style,
access_key_enc, secret_key_enc, session_token_enc
)
VALUES (
%s, %s, %s, %s, %s,
AES_ENCRYPT(%s, %s),
AES_ENCRYPT(%s, %s),
CASE WHEN %s = '' THEN NULL ELSE AES_ENCRYPT(%s, %s) END
)
""",
(
folder_id,
label,
endpoint_url,
region,
int(force_path_style),
access_key,
key,
secret_key,
key,
session_token,
session_token,
key,
),
)
aid = cur.lastrowid
return jsonify({"id": aid}), 201
@app.route("/api/accounts/<int:aid>", methods=["PATCH"])
@require_login
def update_account(aid: int):
body = request.get_json(silent=True) or {}
fields = []
args: list[Any] = []
if "label" in body:
fields.append("label = %s")
args.append(str(body["label"]).strip())
if "endpoint_url" in body:
val = (body.get("endpoint_url") or "").strip() or None
fields.append("endpoint_url = %s")
args.append(val)
if "region" in body:
val = (body.get("region") or "").strip() or None
fields.append("region = %s")
args.append(val)
if "force_path_style" in body:
fields.append("force_path_style = %s")
args.append(1 if body.get("force_path_style") else 0)
if "folder_id" in body:
p = body.get("folder_id")
folder_id = int(p) if p is not None and p != "" else None
if folder_id is not None:
with db_cursor() as (_, cur):
cur.execute("SELECT id FROM s3_folders WHERE id = %s", (folder_id,))
if not cur.fetchone():
return jsonify({"error": "folder not found"}), 400
fields.append("folder_id = %s")
args.append(folder_id)
key = _aes_key()
if "access_key" in body:
fields.append("access_key_enc = AES_ENCRYPT(%s, %s)")
args.extend([str(body.get("access_key") or ""), key])
if "secret_key" in body:
fields.append("secret_key_enc = AES_ENCRYPT(%s, %s)")
args.extend([str(body.get("secret_key") or ""), key])
if "session_token" in body:
st = str(body.get("session_token") or "")
fields.append("session_token_enc = CASE WHEN %s = '' THEN NULL ELSE AES_ENCRYPT(%s, %s) END")
args.extend([st, st, key])
if not fields:
return jsonify({"ok": True})
args.append(aid)
with db_cursor() as (_, cur):
cur.execute(
f"UPDATE s3_accounts SET {', '.join(fields)} WHERE id = %s",
tuple(args),
)
if cur.rowcount == 0:
return jsonify({"error": "not found"}), 404
return jsonify({"ok": True})
@app.route("/api/accounts/<int:aid>", methods=["DELETE"])
@require_login
def delete_account(aid: int):
with db_cursor() as (_, cur):
cur.execute("DELETE FROM s3_accounts WHERE id = %s", (aid,))
if cur.rowcount == 0:
return jsonify({"error": "not found"}), 404
return jsonify({"ok": True})
@app.route("/api/s3/<int:aid>/buckets", methods=["GET"])
@require_login
def list_buckets(aid: int):
account = _load_account_credentials(aid)
if not account:
return jsonify({"error": "account not found"}), 404
try:
client = _s3_client(account)
resp = client.list_buckets()
buckets = []
for b in resp.get("Buckets", []):
buckets.append({"name": b.get("Name"), "created_at": b.get("CreationDate")})
return jsonify({"items": buckets})
except Exception as e:
log.exception("list buckets failed")
return jsonify({"error": str(e)}), 400
def _list_objects(client, bucket: str, path: str, q: str = "") -> dict[str, Any]:
prefix = _normalize_path(path)
query = q.strip().lower()
if query:
paginator = client.get_paginator("list_objects_v2")
pages = paginator.paginate(Bucket=bucket, Prefix=prefix)
resp = {"Contents": [], "CommonPrefixes": []}
for page in pages:
for obj in page.get("Contents", []) or []:
key = obj.get("Key") or ""
if key.endswith("/"):
continue
name = key.split("/")[-1].lower()
if query in key.lower() or query in name:
resp["Contents"].append(obj)
folders: list[dict[str, str]] = []
else:
resp = _paginate_list_objects(client, bucket, prefix)
folders = []
for cp in resp.get("CommonPrefixes", []) or []:
p = cp.get("Prefix") or ""
name = p.rstrip("/").split("/")[-1] if p else ""
if p:
folders.append({"prefix": p, "name": name})
files = []
for obj in resp.get("Contents", []) or []:
key = obj.get("Key")
if not key:
continue
if key.endswith("/"):
continue
name = key.split("/")[-1]
files.append(
{
"key": key,
"name": name,
"size": int(obj.get("Size") or 0),
"last_modified": obj.get("LastModified"),
}
)
return {"prefix": prefix, "folders": folders, "files": files}
@app.route("/api/s3/<int:aid>/objects/list", methods=["POST"])
@require_login
def list_objects(aid: int):
body = request.get_json(silent=True) or {}
bucket = (body.get("bucket") or "").strip()
path = (body.get("path") or "").strip()
q = (body.get("q") or "").strip()
if not bucket:
return jsonify({"error": "bucket required"}), 400
account = _load_account_credentials(aid)
if not account:
return jsonify({"error": "account not found"}), 404
try:
client = _s3_client(account)
data = _list_objects(client, bucket, path, q)
return jsonify({"bucket": bucket, "path": path or "/", **data})
except Exception as e:
log.exception("list objects failed")
return jsonify({"error": str(e)}), 400
@app.route("/api/s3/<int:aid>/objects/mkdir", methods=["POST"])
@require_login
def create_prefix(aid: int):
body = request.get_json(silent=True) or {}
bucket = (body.get("bucket") or "").strip()
path = (body.get("path") or "").strip()
name = (body.get("name") or "").strip().strip("/")
if not bucket or not name:
return jsonify({"error": "bucket and name required"}), 400
account = _load_account_credentials(aid)
if not account:
return jsonify({"error": "account not found"}), 404
prefix = _normalize_path(path)
key = _join_key(prefix, f"{name}/")
try:
client = _s3_client(account)
client.put_object(Bucket=bucket, Key=key, Body=b"")
return jsonify({"ok": True, "key": key})
except Exception as e:
log.exception("mkdir failed")
return jsonify({"error": str(e)}), 400
def _delete_objects(client, bucket: str, keys: Iterable[str]) -> None:
batch = []
for k in keys:
batch.append({"Key": k})
if len(batch) == 1000:
client.delete_objects(Bucket=bucket, Delete={"Objects": batch})
batch = []
if batch:
client.delete_objects(Bucket=bucket, Delete={"Objects": batch})
@app.route("/api/s3/<int:aid>/objects/delete", methods=["POST"])
@require_login
def delete_object(aid: int):
body = request.get_json(silent=True) or {}
bucket = (body.get("bucket") or "").strip()
key = (body.get("key") or "").strip()
if not bucket or not key:
return jsonify({"error": "bucket and key required"}), 400
account = _load_account_credentials(aid)
if not account:
return jsonify({"error": "account not found"}), 404
try:
client = _s3_client(account)
if key.endswith("/"):
resp = client.list_objects_v2(Bucket=bucket, Prefix=key)
keys = [o.get("Key") for o in resp.get("Contents", []) or [] if o.get("Key")]
_delete_objects(client, bucket, keys)
else:
client.delete_object(Bucket=bucket, Key=key)
return jsonify({"ok": True})
except Exception as e:
log.exception("delete failed")
return jsonify({"error": str(e)}), 400
@app.route("/api/s3/<int:aid>/objects/rename", methods=["POST"])
@require_login
def rename_object(aid: int):
body = request.get_json(silent=True) or {}
bucket = (body.get("bucket") or "").strip()
old_key = (body.get("old_key") or "").strip()
new_key = (body.get("new_key") or "").strip()
if not bucket or not old_key or not new_key:
return jsonify({"error": "bucket, old_key, new_key required"}), 400
account = _load_account_credentials(aid)
if not account:
return jsonify({"error": "account not found"}), 404
try:
client = _s3_client(account)
if old_key.endswith("/"):
resp = client.list_objects_v2(Bucket=bucket, Prefix=old_key)
keys = [o.get("Key") for o in resp.get("Contents", []) or [] if o.get("Key")]
for k in keys:
suffix = k[len(old_key):]
dest = new_key + suffix
client.copy_object(Bucket=bucket, CopySource={"Bucket": bucket, "Key": k}, Key=dest)
_delete_objects(client, bucket, keys)
else:
client.copy_object(Bucket=bucket, CopySource={"Bucket": bucket, "Key": old_key}, Key=new_key)
client.delete_object(Bucket=bucket, Key=old_key)
return jsonify({"ok": True})
except Exception as e:
log.exception("rename failed")
return jsonify({"error": str(e)}), 400
@app.route("/api/s3/<int:aid>/objects/upload", methods=["POST"])
@require_login
def upload_object(aid: int):
bucket = (request.form.get("bucket") or "").strip()
path = (request.form.get("path") or "").strip()
if not bucket:
return jsonify({"error": "bucket required"}), 400
f = request.files.get("file")
if not f or not f.filename:
return jsonify({"error": "file required"}), 400
safe_name = secure_filename(f.filename)
if not safe_name:
return jsonify({"error": "invalid filename"}), 400
prefix = _normalize_path(path)
key = _join_key(prefix, safe_name)
account = _load_account_credentials(aid)
if not account:
return jsonify({"error": "account not found"}), 404
try:
client = _s3_client(account)
client.upload_fileobj(f.stream, bucket, key)
return jsonify({"ok": True, "key": key})
except Exception as e:
log.exception("upload failed")
return jsonify({"error": str(e)}), 400
@app.route("/api/s3/<int:aid>/objects/download", methods=["GET"])
@require_login
def download_object(aid: int):
bucket = (request.args.get("bucket") or "").strip()
key = (request.args.get("key") or "").strip()
if not bucket or not key:
abort(400)
account = _load_account_credentials(aid)
if not account:
abort(404)
try:
client = _s3_client(account)
obj = client.get_object(Bucket=bucket, Key=key)
body = obj.get("Body")
if body is None:
abort(404)
def gen():
for chunk in iter(lambda: body.read(65536), b""):
yield chunk
name = posixpath.basename(key) or "download"
return Response(
gen(),
mimetype="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{name}"'},
)
except Exception:
abort(400)
STATIC_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
DIST = os.path.join(STATIC_ROOT, "dist")
PWA_STATIC_FILES = frozenset({"manifest.webmanifest", "sw.js"})
@app.route("/assets/<path:sub>")
def spa_assets(sub):
return send_from_directory(os.path.join(DIST, "assets"), sub)
@app.route("/", defaults={"path": ""})
@app.route("/<path:path>")
def spa(path):
if path.startswith("api"):
abort(404)
index = os.path.join(DIST, "index.html")
if not os.path.isfile(index):
return (
"Frontend not built. Run: cd frontend && npm ci && npm run build",
503,
)
if path in PWA_STATIC_FILES:
try:
pwa_path = safe_join(STATIC_ROOT, path)
except ValueError:
pwa_path = None
if pwa_path and os.path.isfile(pwa_path):
if path.endswith(".webmanifest"):
return send_file(pwa_path, mimetype="application/manifest+json")
return send_file(pwa_path, mimetype="application/javascript")
if path:
try:
file_path = safe_join(DIST, path)
except ValueError:
file_path = None
if file_path and os.path.isfile(file_path):
if path.endswith(".webmanifest"):
return send_file(file_path, mimetype="application/manifest+json")
if path.endswith(".js"):
return send_file(file_path, mimetype="application/javascript")
return send_file(file_path)
return send_from_directory(DIST, "index.html")
with app.app_context():
try:
init_db()
except Exception as e:
log.warning("init_db skipped (DB unavailable): %s", e)
if __name__ == "__main__":
app.run(
host="0.0.0.0",
port=int(os.getenv("PORT", "5000")),
debug=os.getenv("FLASK_DEBUG", "").lower() in ("1", "true", "yes"),
)
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link
rel="icon"
type="image/png"
href="https://assets.jdbnet.co.uk/projects/s3.png"
/>
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0f1115" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<title>S3 Client</title>
</head>
<body class="app-shell bg-surface text-slate-200 antialiased">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2473
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "s3-client-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-vue-next": "^1.0.0",
"pinia": "^2.2.6",
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "~5.6.3",
"vite": "^6.0.3",
"vue-tsc": "^2.1.10"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+584
View File
@@ -0,0 +1,584 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import {
Boxes,
ChevronRight,
Folder,
LogOut,
Pencil,
Plus,
Search,
Trash2,
Cloud,
} from "lucide-vue-next";
import {
api,
type AccountRow,
type FolderRow,
} from "@/api";
import LoginForm from "@/components/LoginForm.vue";
import AccountTab from "@/components/AccountTab.vue";
interface TabItem {
id: string;
accountId: number;
label: string;
}
const loggedIn = ref(false);
const checking = ref(true);
const appVersion = ref("unknown");
const loadErr = ref("");
const allAccounts = ref<AccountRow[]>([]);
const allFolders = ref<FolderRow[]>([]);
const browseFolders = ref<FolderRow[]>([]);
const browseAccounts = ref<AccountRow[]>([]);
const breadcrumb = ref<{ id: number; label: string }[]>([]);
const searchActive = ref(false);
const currentFolderId = ref<number | null>(null);
const searchQuery = ref("");
const sidebarOpen = ref(true);
const tabs = ref<TabItem[]>([]);
const activeTabId = ref<string | null>(null);
let searchDebounceTimer = 0;
const showAccountForm = ref(false);
const showEditAccount = ref(false);
const showFolderForm = ref(false);
const showEditFolder = ref(false);
const newFolderLabel = ref("");
const showingFolderSearch = ref(false);
const accountForm = ref({
label: "",
endpoint_url: "",
region: "",
force_path_style: false,
access_key: "",
secret_key: "",
session_token: "",
});
const editAccountForm = ref({
id: 0,
label: "",
endpoint_url: "",
region: "",
force_path_style: false,
access_key: "",
secret_key: "",
session_token: "",
folder_id: null as number | null,
});
const editFolderForm = ref({
id: 0,
label: "",
parent_id: null as number | null,
});
function folderOptionLabel(id: number | null): string {
if (id == null) return "(root)";
const byId = new Map(allFolders.value.map((f) => [f.id, f]));
const parts: string[] = [];
let cur: number | null | undefined = id;
const guard = new Set<number>();
while (cur != null && !guard.has(cur)) {
guard.add(cur);
const f = byId.get(cur);
if (!f) break;
parts.unshift(f.label);
cur = f.parent_id;
}
return parts.join(" / ") || `#${id}`;
}
async function refreshBrowse() {
try {
const d = await api.browse(currentFolderId.value, searchQuery.value);
browseFolders.value = d.folders;
browseAccounts.value = d.accounts;
breadcrumb.value = d.breadcrumb;
searchActive.value = d.search_active;
} catch (e) {
loadErr.value = e instanceof Error ? e.message : "Browse failed";
}
}
function onSearchInput() {
window.clearTimeout(searchDebounceTimer);
searchDebounceTimer = window.setTimeout(() => {
void refreshBrowse();
}, 300);
}
function goToFolder(id: number | null) {
currentFolderId.value = id;
searchQuery.value = "";
showingFolderSearch.value = false;
void refreshBrowse();
}
function openSearchMode() {
showingFolderSearch.value = true;
}
function clearSearch() {
searchQuery.value = "";
showingFolderSearch.value = false;
void refreshBrowse();
}
async function refreshData() {
loadErr.value = "";
try {
allAccounts.value = await api.listAccounts();
allFolders.value = await api.listFoldersFlat();
await refreshBrowse();
} catch (e) {
loadErr.value = e instanceof Error ? e.message : "Failed to load data";
}
}
onMounted(async () => {
try {
const m = await api.me();
loggedIn.value = m.logged_in;
if (m.app_version) {
appVersion.value = m.app_version;
}
if (loggedIn.value) await refreshData();
} catch {
loggedIn.value = false;
} finally {
checking.value = false;
}
});
async function onLoggedIn() {
loggedIn.value = true;
await refreshData();
}
async function logout() {
await api.logout();
tabs.value = [];
activeTabId.value = null;
loggedIn.value = false;
}
function openTab(a: AccountRow) {
const id = crypto.randomUUID();
tabs.value.push({ id, accountId: a.id, label: a.label });
activeTabId.value = id;
if (window.matchMedia("(max-width: 1023px)").matches) {
sidebarOpen.value = false;
}
}
function closeTab(id: string) {
tabs.value = tabs.value.filter((t) => t.id !== id);
if (activeTabId.value === id) {
activeTabId.value = tabs.value.length ? tabs.value[tabs.value.length - 1].id : null;
}
}
async function submitAccount() {
const f = accountForm.value;
await api.createAccount({
label: f.label.trim(),
endpoint_url: f.endpoint_url.trim() || null,
region: f.region.trim() || null,
force_path_style: f.force_path_style,
access_key: f.access_key.trim(),
secret_key: f.secret_key.trim(),
session_token: f.session_token.trim(),
folder_id: currentFolderId.value,
});
showAccountForm.value = false;
accountForm.value = {
label: "",
endpoint_url: "",
region: "",
force_path_style: false,
access_key: "",
secret_key: "",
session_token: "",
};
await refreshData();
}
function openEditAccount(a: AccountRow) {
editAccountForm.value = {
id: a.id,
label: a.label,
endpoint_url: a.endpoint_url || "",
region: a.region || "",
force_path_style: Boolean(a.force_path_style),
access_key: "",
secret_key: "",
session_token: "",
folder_id: a.folder_id,
};
showEditAccount.value = true;
}
async function submitEditAccount() {
const f = editAccountForm.value;
const body: Record<string, unknown> = {
label: f.label.trim(),
endpoint_url: f.endpoint_url.trim() || null,
region: f.region.trim() || null,
force_path_style: f.force_path_style,
folder_id: f.folder_id,
};
if (f.access_key.trim()) body.access_key = f.access_key.trim();
if (f.secret_key.trim()) body.secret_key = f.secret_key.trim();
if (f.session_token.trim()) body.session_token = f.session_token.trim();
await api.updateAccount(f.id, body);
showEditAccount.value = false;
await refreshData();
}
async function deleteAccount(a: AccountRow) {
if (!confirm(`Delete ${a.label}?`)) return;
await api.deleteAccount(a.id);
tabs.value = tabs.value.filter((t) => t.accountId !== a.id);
if (activeTabId.value && !tabs.value.find((t) => t.id === activeTabId.value)) {
activeTabId.value = tabs.value.length ? tabs.value[tabs.value.length - 1].id : null;
}
await refreshData();
}
async function submitFolder() {
const label = newFolderLabel.value.trim();
if (!label) return;
await api.createFolder({ label, parent_id: currentFolderId.value });
newFolderLabel.value = "";
showFolderForm.value = false;
await refreshBrowse();
}
function openEditFolder(f: FolderRow) {
editFolderForm.value = {
id: f.id,
label: f.label,
parent_id: f.parent_id,
};
showEditFolder.value = true;
}
async function submitEditFolder() {
const f = editFolderForm.value;
await api.updateFolder(f.id, {
label: f.label.trim(),
parent_id: f.parent_id,
});
showEditFolder.value = false;
await refreshBrowse();
}
async function deleteFolder(f: FolderRow) {
if (!confirm(`Delete ${f.label}?`)) return;
await api.deleteFolder(f.id);
await refreshBrowse();
}
</script>
<template>
<div v-if="checking" class="flex min-h-screen items-center justify-center text-slate-400">
Loading...
</div>
<LoginForm v-else-if="!loggedIn" @logged-in="onLoggedIn" />
<div v-else class="min-h-screen">
<header class="border-b border-slate-800/80 bg-surface/80 backdrop-blur">
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<div class="flex items-center gap-3">
<div class="rounded-2xl bg-accent/20 p-2 text-accent">
<Boxes class="h-5 w-5" />
</div>
<div>
<h1 class="font-sans text-lg font-semibold tracking-tight text-white">S3 Client</h1>
<p class="text-xs text-slate-500">{{ appVersion }}</p>
</div>
</div>
<button type="button" class="button-secondary" @click="logout">
<LogOut class="h-4 w-4" />
Sign out
</button>
</div>
</header>
<div class="mx-auto flex max-w-7xl flex-1 gap-6 px-6 py-6">
<aside
class="panel w-80 shrink-0 flex-col"
:class="sidebarOpen ? 'flex' : 'hidden lg:flex'"
>
<div class="border-b border-slate-800/80 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Workspace</p>
<h2 class="text-base font-semibold text-white">Accounts</h2>
</div>
<button
type="button"
class="button-secondary"
@click="showAccountForm = true"
>
<Plus class="h-4 w-4" />
New
</button>
</div>
<div class="mt-4 space-y-2">
<div class="relative">
<Search class="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-slate-500" />
<input
v-model="searchQuery"
type="search"
class="field pl-9"
@input="onSearchInput"
@focus="openSearchMode"
/>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="button-secondary flex-1"
@click="showFolderForm = true"
>
<Folder class="h-4 w-4" />
New folder
</button>
<button
v-if="searchQuery"
type="button"
class="button-secondary"
@click="clearSearch"
>
Clear
</button>
</div>
</div>
</div>
<div class="flex-1 overflow-auto p-4">
<p v-if="loadErr" class="mb-3 text-sm text-red-400">{{ loadErr }}</p>
<div v-if="breadcrumb.length" class="mb-4 flex flex-wrap items-center gap-2 text-xs text-slate-400">
<button type="button" class="hover:text-slate-200" @click="goToFolder(null)">Root</button>
<ChevronRight class="h-3 w-3" />
<button
v-for="b in breadcrumb"
:key="b.id"
type="button"
class="hover:text-slate-200"
@click="goToFolder(b.id)"
>
{{ b.label }}
</button>
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Folders</p>
<ul class="mt-2 space-y-1">
<li
v-for="f in browseFolders"
:key="f.id"
class="group flex items-center gap-2 rounded-lg px-3 py-2 hover:bg-surface-overlay"
>
<button
type="button"
class="flex-1 truncate text-left text-sm text-slate-200"
@click="goToFolder(f.id)"
>
<span class="inline-flex items-center gap-2">
<Folder class="h-4 w-4 text-accent" />
{{ f.label }}
</span>
</button>
<button
type="button"
class="text-xs text-slate-500 opacity-0 transition group-hover:opacity-100"
@click="openEditFolder(f)"
>
<Pencil class="h-4 w-4" />
</button>
<button
type="button"
class="text-xs text-red-400/80 opacity-0 transition group-hover:opacity-100"
@click="deleteFolder(f)"
>
<Trash2 class="h-4 w-4" />
</button>
</li>
<li v-if="!browseFolders.length" class="text-xs text-slate-500">No folders</li>
</ul>
</div>
<div class="mt-6">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
{{ searchQuery ? "Search results" : "Accounts" }}
</p>
<ul class="mt-2 space-y-2">
<li
v-for="a in browseAccounts"
:key="a.id"
class="rounded-lg border border-slate-800 bg-surface-overlay p-3"
>
<button type="button" class="w-full text-left" @click="openTab(a)">
<div class="flex items-center justify-between">
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-white">{{ a.label }}</p>
<p class="truncate text-xs text-slate-500">{{ a.endpoint_url || "AWS S3" }}</p>
</div>
<Cloud class="h-4 w-4 text-accent" />
</div>
</button>
<div class="mt-3 flex items-center gap-2 text-xs text-slate-400">
<button type="button" class="hover:text-slate-200" @click="openEditAccount(a)">Edit</button>
<button type="button" class="hover:text-red-400" @click="deleteAccount(a)">Delete</button>
</div>
</li>
<li v-if="!browseAccounts.length" class="text-xs text-slate-500">No accounts</li>
</ul>
</div>
</div>
</aside>
<main class="min-h-[640px] flex-1">
<div v-if="!tabs.length" class="panel flex h-full items-center justify-center p-10">
<div class="max-w-md text-center">
<p class="text-lg font-semibold text-white">Open an account to start</p>
<p class="mt-2 text-sm text-slate-400">
Browse buckets, manage folders, and upload objects once an account is selected.
</p>
</div>
</div>
<div v-else class="flex h-full flex-col">
<div class="flex flex-wrap gap-2">
<button
v-for="t in tabs"
:key="t.id"
type="button"
class="rounded-lg border border-slate-800 px-4 py-2 text-sm"
:class="activeTabId === t.id ? 'bg-accent/20 text-white' : 'bg-surface-overlay text-slate-400'"
@click="activeTabId = t.id"
>
{{ t.label }}
<span class="ml-2 text-slate-500" @click.stop="closeTab(t.id)">x</span>
</button>
</div>
<div class="mt-4 flex-1">
<AccountTab
v-for="t in tabs"
:key="t.id"
:account-id="t.accountId"
:account-label="t.label"
:visible="activeTabId === t.id"
v-show="activeTabId === t.id"
/>
</div>
</div>
</main>
</div>
<div v-if="showAccountForm" class="fixed inset-0 z-40 flex items-center justify-center bg-black/50 p-6">
<div class="panel w-full max-w-xl p-6">
<h3 class="text-lg font-semibold text-white">New account</h3>
<form class="mt-4 space-y-4" @submit.prevent="submitAccount">
<input v-model="accountForm.label" class="field" placeholder="Label" required />
<div class="grid gap-3 md:grid-cols-2">
<input v-model="accountForm.endpoint_url" class="field" placeholder="Endpoint URL" />
<input v-model="accountForm.region" class="field" placeholder="Region" />
</div>
<label class="flex items-center gap-2 text-sm text-slate-300">
<input v-model="accountForm.force_path_style" type="checkbox" />
Force path style
</label>
<div class="grid gap-3 md:grid-cols-2">
<input v-model="accountForm.access_key" class="field" placeholder="Access key" required />
<input v-model="accountForm.secret_key" class="field" placeholder="Secret key" required />
</div>
<input v-model="accountForm.session_token" class="field" placeholder="Session token (optional)" />
<div class="flex justify-end gap-2">
<button type="button" class="button-secondary" @click="showAccountForm = false">Cancel</button>
<button type="submit" class="button-primary">Create</button>
</div>
</form>
</div>
</div>
<div v-if="showEditAccount" class="fixed inset-0 z-40 flex items-center justify-center bg-black/50 p-6">
<div class="panel w-full max-w-xl p-6">
<h3 class="text-lg font-semibold text-white">Edit account</h3>
<form class="mt-4 space-y-4" @submit.prevent="submitEditAccount">
<input v-model="editAccountForm.label" class="field" placeholder="Label" required />
<div class="grid gap-3 md:grid-cols-2">
<input v-model="editAccountForm.endpoint_url" class="field" placeholder="Endpoint URL" />
<input v-model="editAccountForm.region" class="field" placeholder="Region" />
</div>
<label class="flex items-center gap-2 text-sm text-slate-300">
<input v-model="editAccountForm.force_path_style" type="checkbox" />
Force path style
</label>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Folder</label>
<select v-model="editAccountForm.folder_id" class="field">
<option :value="null">(root)</option>
<option v-for="f in allFolders" :key="f.id" :value="f.id">
{{ folderOptionLabel(f.id) }}
</option>
</select>
</div>
<div class="grid gap-3 md:grid-cols-2">
<input v-model="editAccountForm.access_key" class="field" placeholder="New access key (optional)" />
<input v-model="editAccountForm.secret_key" class="field" placeholder="New secret key (optional)" />
</div>
<input v-model="editAccountForm.session_token" class="field" placeholder="New session token (optional)" />
<div class="flex justify-end gap-2">
<button type="button" class="button-secondary" @click="showEditAccount = false">Cancel</button>
<button type="submit" class="button-primary">Save</button>
</div>
</form>
</div>
</div>
<div v-if="showFolderForm" class="fixed inset-0 z-40 flex items-center justify-center bg-black/50 p-6">
<div class="panel w-full max-w-md p-6">
<h3 class="text-lg font-semibold text-white">New folder</h3>
<form class="mt-4 space-y-4" @submit.prevent="submitFolder">
<input v-model="newFolderLabel" class="field" placeholder="Folder name" required />
<div class="flex justify-end gap-2">
<button type="button" class="button-secondary" @click="showFolderForm = false">Cancel</button>
<button type="submit" class="button-primary">Create</button>
</div>
</form>
</div>
</div>
<div v-if="showEditFolder" class="fixed inset-0 z-40 flex items-center justify-center bg-black/50 p-6">
<div class="panel w-full max-w-md p-6">
<h3 class="text-lg font-semibold text-white">Edit folder</h3>
<form class="mt-4 space-y-4" @submit.prevent="submitEditFolder">
<input v-model="editFolderForm.label" class="field" placeholder="Folder name" required />
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Parent</label>
<select v-model="editFolderForm.parent_id" class="field">
<option :value="null">(root)</option>
<option v-for="f in allFolders" :key="f.id" :value="f.id">
{{ folderOptionLabel(f.id) }}
</option>
</select>
</div>
<div class="flex justify-end gap-2">
<button type="button" class="button-secondary" @click="showEditFolder = false">Cancel</button>
<button type="submit" class="button-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
</template>
+264
View File
@@ -0,0 +1,264 @@
const jsonHeaders = { "Content-Type": "application/json" };
async function handle<T>(res: Response): Promise<T> {
if (res.status === 401) {
throw new Error("unauthorized");
}
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error((data as { error?: string }).error || res.statusText);
}
return data as T;
}
function browseParams(folderId: number | null, q: string): string {
const p = new URLSearchParams();
if (folderId != null) p.set("folder_id", String(folderId));
else p.set("folder_id", "root");
const t = q.trim();
if (t) p.set("q", t);
return p.toString();
}
export const api = {
async me(): Promise<{ logged_in: boolean; app_version?: string }> {
const res = await fetch("/api/me", { credentials: "include" });
return handle(res);
},
async login(username: string, password: string): Promise<void> {
const res = await fetch("/api/login", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ username, password }),
});
await handle(res);
},
async logout(): Promise<void> {
const res = await fetch("/api/logout", {
method: "POST",
credentials: "include",
});
await handle(res);
},
async browse(
folderId: number | null,
q: string,
): Promise<{
breadcrumb: { id: number; label: string }[];
folders: FolderRow[];
accounts: AccountRow[];
search_active: boolean;
}> {
const res = await fetch(`/api/browse?${browseParams(folderId, q)}`, {
credentials: "include",
});
return handle(res);
},
async listFoldersFlat(): Promise<FolderRow[]> {
const res = await fetch("/api/folders", { credentials: "include" });
const d = await handle<{ items: FolderRow[] }>(res);
return d.items;
},
async createFolder(body: {
label: string;
parent_id?: number | null;
}): Promise<{ id: number }> {
const res = await fetch("/api/folders", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
return handle(res);
},
async deleteFolder(id: number): Promise<void> {
const res = await fetch(`/api/folders/${id}`, {
method: "DELETE",
credentials: "include",
});
await handle(res);
},
async updateFolder(
id: number,
body: {
label?: string;
parent_id?: number | null;
},
): Promise<void> {
const res = await fetch(`/api/folders/${id}`, {
method: "PATCH",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
await handle(res);
},
async listAccounts(): Promise<AccountRow[]> {
const res = await fetch("/api/accounts", { credentials: "include" });
const d = await handle<{ items: AccountRow[] }>(res);
return d.items;
},
async createAccount(body: Record<string, unknown>): Promise<{ id: number }> {
const res = await fetch("/api/accounts", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
return handle(res);
},
async updateAccount(id: number, body: Record<string, unknown>): Promise<void> {
const res = await fetch(`/api/accounts/${id}`, {
method: "PATCH",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
await handle(res);
},
async deleteAccount(id: number): Promise<void> {
const res = await fetch(`/api/accounts/${id}`, {
method: "DELETE",
credentials: "include",
});
await handle(res);
},
async listBuckets(accountId: number): Promise<BucketRow[]> {
const res = await fetch(`/api/s3/${accountId}/buckets`, {
credentials: "include",
});
const d = await handle<{ items: BucketRow[] }>(res);
return d.items;
},
async listObjects(
accountId: number,
bucket: string,
path: string,
q = "",
): Promise<{ prefix: string; folders: S3FolderEntry[]; files: S3FileEntry[] }>
{
const body: Record<string, string> = { bucket, path };
if (q.trim()) body.q = q.trim();
const res = await fetch(`/api/s3/${accountId}/objects/list`, {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
return handle(res);
},
async mkdir(
accountId: number,
bucket: string,
path: string,
name: string,
): Promise<void> {
const res = await fetch(`/api/s3/${accountId}/objects/mkdir`, {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ bucket, path, name }),
});
await handle(res);
},
async deleteObject(
accountId: number,
bucket: string,
key: string,
): Promise<void> {
const res = await fetch(`/api/s3/${accountId}/objects/delete`, {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ bucket, key }),
});
await handle(res);
},
async renameObject(
accountId: number,
bucket: string,
oldKey: string,
newKey: string,
): Promise<void> {
const res = await fetch(`/api/s3/${accountId}/objects/rename`, {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ bucket, old_key: oldKey, new_key: newKey }),
});
await handle(res);
},
async uploadObject(
accountId: number,
bucket: string,
path: string,
file: File,
): Promise<void> {
const fd = new FormData();
fd.set("bucket", bucket);
fd.set("path", path);
fd.set("file", file);
const res = await fetch(`/api/s3/${accountId}/objects/upload`, {
method: "POST",
credentials: "include",
body: fd,
});
await handle(res);
},
downloadUrl(accountId: number, bucket: string, key: string): string {
const q = new URLSearchParams({ bucket, key });
return `/api/s3/${accountId}/objects/download?${q}`;
},
};
export interface FolderRow {
id: number;
label: string;
parent_id: number | null;
}
export interface AccountRow {
id: number;
folder_id: number | null;
label: string;
endpoint_url: string | null;
region: string | null;
force_path_style: number | boolean;
folder_label?: string | null;
}
export interface BucketRow {
name: string;
created_at?: string | null;
}
export interface S3FolderEntry {
prefix: string;
name: string;
}
export interface S3FileEntry {
key: string;
name: string;
size: number;
last_modified?: string | null;
}
+409
View File
@@ -0,0 +1,409 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import {
Download,
Folder,
File,
RefreshCcw,
Trash2,
Pencil,
Upload,
Search,
Plus,
} from "lucide-vue-next";
import { api, type BucketRow, type S3FileEntry, type S3FolderEntry } from "@/api";
const props = defineProps<{
accountId: number;
accountLabel: string;
visible: boolean;
}>();
const buckets = ref<BucketRow[]>([]);
const bucket = ref("");
const path = ref("/");
const folders = ref<S3FolderEntry[]>([]);
const files = ref<S3FileEntry[]>([]);
const err = ref("");
const searchQuery = ref("");
const bucketsLoading = ref(false);
const objectsLoading = ref(false);
const renameTarget = ref<{
kind: "folder" | "file";
name: string;
key: string;
} | null>(null);
const renameValue = ref("");
let searchTimer: number | undefined;
const currentPrefix = computed(() => {
if (!path.value || path.value === "/") return "";
return path.value.replace(/^\//, "");
});
function joinPath(base: string, name: string): string {
const cleaned = base === "/" ? "" : base.replace(/\/$/, "");
const parts = [cleaned, name].filter(Boolean);
return `/${parts.join("/")}/`;
}
function parentPath(): string {
if (path.value === "/") return "/";
const parts = path.value.replace(/^\//, "").replace(/\/$/, "").split("/");
parts.pop();
return parts.length ? `/${parts.join("/")}/` : "/";
}
function fmtDate(ts?: string | null): string {
if (!ts) return "-";
const d = new Date(ts);
return Number.isNaN(d.getTime()) ? ts : d.toLocaleString();
}
function fmtSize(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
return `${(n / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
async function loadBuckets() {
err.value = "";
bucketsLoading.value = true;
try {
buckets.value = await api.listBuckets(props.accountId);
if (!bucket.value && buckets.value.length) {
bucket.value = buckets.value[0].name;
await loadObjects();
}
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed to load buckets";
} finally {
bucketsLoading.value = false;
}
}
async function loadObjects() {
if (!bucket.value) return;
err.value = "";
objectsLoading.value = true;
try {
const res = await api.listObjects(props.accountId, bucket.value, path.value, searchQuery.value);
folders.value = res.folders;
files.value = res.files;
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed to load objects";
} finally {
objectsLoading.value = false;
}
}
function selectBucket(name: string) {
bucket.value = name;
path.value = "/";
searchQuery.value = "";
loadObjects();
}
function enterFolder(folder: S3FolderEntry) {
const name = folder.name || folder.prefix.replace(/\/$/, "").split("/").pop() || "";
if (!name) return;
path.value = joinPath(path.value, name);
loadObjects();
}
function goUp() {
path.value = parentPath();
loadObjects();
}
async function onUpload(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file || !bucket.value) return;
try {
await api.uploadObject(props.accountId, bucket.value, path.value, file);
await loadObjects();
} catch (e) {
err.value = e instanceof Error ? e.message : "Upload failed";
}
}
async function downloadFile(entry: S3FileEntry) {
if (!bucket.value) return;
err.value = "";
try {
const res = await fetch(api.downloadUrl(props.accountId, bucket.value, entry.key), {
credentials: "include",
});
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = entry.name;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
err.value = e instanceof Error ? e.message : "Download failed";
}
}
async function removeEntry(entry: S3FileEntry | S3FolderEntry, kind: "file" | "folder") {
if (!bucket.value) return;
if (!confirm(`Delete ${"name" in entry ? entry.name : "item"}?`)) return;
const key = kind === "folder" ? (entry as S3FolderEntry).prefix : (entry as S3FileEntry).key;
try {
await api.deleteObject(props.accountId, bucket.value, key);
await loadObjects();
} catch (e) {
err.value = e instanceof Error ? e.message : "Delete failed";
}
}
function startRename(entry: S3FileEntry | S3FolderEntry, kind: "file" | "folder") {
renameTarget.value = {
kind,
name: "name" in entry ? entry.name : "",
key: kind === "folder" ? (entry as S3FolderEntry).prefix : (entry as S3FileEntry).key,
};
renameValue.value = renameTarget.value.name;
}
async function confirmRename() {
if (!renameTarget.value || !bucket.value) return;
const name = renameValue.value.trim();
if (!name) return;
let newKey = name;
if (renameTarget.value.kind === "folder") {
const base = currentPrefix.value;
newKey = base ? `${base}${name}/` : `${name}/`;
} else {
const base = currentPrefix.value;
newKey = base ? `${base}${name}` : name;
}
try {
await api.renameObject(props.accountId, bucket.value, renameTarget.value.key, newKey);
renameTarget.value = null;
await loadObjects();
} catch (e) {
err.value = e instanceof Error ? e.message : "Rename failed";
}
}
async function mkdir() {
if (!bucket.value) return;
const name = prompt("Folder name");
if (!name?.trim()) return;
try {
await api.mkdir(props.accountId, bucket.value, path.value, name.trim());
await loadObjects();
} catch (e) {
err.value = e instanceof Error ? e.message : "Create folder failed";
}
}
function queueSearch() {
window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
void loadObjects();
}, 250);
}
watch(
() => props.accountId,
() => {
bucket.value = "";
path.value = "/";
searchQuery.value = "";
folders.value = [];
files.value = [];
void loadBuckets();
},
{ immediate: true },
);
watch(
() => props.visible,
(visible) => {
if (visible && !buckets.value.length) {
void loadBuckets();
}
},
);
</script>
<template>
<div class="flex h-full min-h-0 flex-col gap-4">
<div class="grid gap-4 lg:grid-cols-[280px_minmax(0,1fr)]">
<div class="panel p-4">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">Buckets</p>
<h2 class="font-sans text-base font-semibold text-white">{{ accountLabel }}</h2>
</div>
<button type="button" class="button-secondary" @click="loadBuckets">
<RefreshCcw class="h-4 w-4" />
Refresh
</button>
</div>
<div class="mt-4">
<p v-if="bucketsLoading" class="text-xs text-slate-500">Loading buckets...</p>
<ul v-else class="space-y-2">
<li v-for="b in buckets" :key="b.name">
<button
type="button"
class="w-full rounded-lg border border-slate-800 bg-surface-overlay px-3 py-2 text-left text-sm transition hover:border-slate-600"
:class="bucket === b.name ? 'border-accent bg-accent/15 text-white' : 'text-slate-300'"
@click="selectBucket(b.name)"
>
<div class="flex items-center justify-between gap-2">
<span class="truncate font-medium">{{ b.name }}</span>
<span class="text-[11px] text-slate-500">{{ fmtDate(b.created_at) }}</span>
</div>
</button>
</li>
</ul>
</div>
</div>
<div class="panel flex min-h-[420px] flex-col">
<div class="border-b border-slate-800 px-4 py-3">
<div class="flex flex-wrap items-center gap-3">
<div class="flex-1">
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">Bucket path</p>
<p class="font-mono text-xs text-slate-300">
{{ bucket ? `s3://${bucket}${path}` : "Select a bucket" }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" class="button-secondary" @click="goUp">
<Folder class="h-4 w-4" />
Up
</button>
<button type="button" class="button-secondary" @click="loadObjects">
<RefreshCcw class="h-4 w-4" />
Refresh
</button>
<button type="button" class="button-secondary" @click="mkdir">
<Plus class="h-4 w-4" />
Folder
</button>
<label class="button-primary cursor-pointer">
<Upload class="h-4 w-4" />
Upload
<input type="file" class="hidden" @change="onUpload" />
</label>
</div>
</div>
<div class="mt-3 flex items-center gap-2">
<div class="relative flex-1">
<Search class="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-slate-500" />
<input
v-model="searchQuery"
class="field pl-9"
@input="queueSearch"
/>
</div>
<button type="button" class="button-secondary" @click="searchQuery = ''; loadObjects()">
Clear
</button>
</div>
<p v-if="err" class="mt-3 text-sm text-red-400">{{ err }}</p>
</div>
<div class="min-h-0 flex-1 overflow-auto p-4">
<p v-if="objectsLoading" class="text-sm text-slate-500">Loading objects...</p>
<div v-else class="space-y-6">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">Folders</p>
<ul class="mt-2 space-y-1">
<li
v-for="f in folders"
:key="f.prefix"
class="group flex items-center gap-2 rounded-lg px-3 py-2 hover:bg-surface-overlay"
>
<button type="button" class="flex-1 truncate text-left text-sm text-slate-200" @click="enterFolder(f)">
<span class="inline-flex items-center gap-2">
<Folder class="h-4 w-4 text-accent" />
{{ f.name }}
</span>
</button>
<button
type="button"
class="text-xs text-slate-500 opacity-0 transition group-hover:opacity-100"
@click="startRename(f, 'folder')"
>
<Pencil class="h-4 w-4" />
</button>
<button
type="button"
class="text-xs text-red-400/80 opacity-0 transition group-hover:opacity-100"
@click="removeEntry(f, 'folder')"
>
<Trash2 class="h-4 w-4" />
</button>
</li>
<li v-if="!folders.length" class="text-xs text-slate-500">No folders</li>
</ul>
</div>
<div>
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">Files</p>
<ul class="mt-2 space-y-1">
<li
v-for="f in files"
:key="f.key"
class="group flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-surface-overlay"
>
<div class="flex-1 truncate text-sm text-slate-200">
<span class="inline-flex items-center gap-2">
<File class="h-4 w-4" />
{{ f.name }}
</span>
</div>
<span class="text-xs text-slate-500">{{ fmtSize(f.size) }}</span>
<button
type="button"
class="text-xs text-slate-400 opacity-0 transition group-hover:opacity-100"
@click="downloadFile(f)"
>
<Download class="h-4 w-4" />
</button>
<button
type="button"
class="text-xs text-slate-400 opacity-0 transition group-hover:opacity-100"
@click="startRename(f, 'file')"
>
<Pencil class="h-4 w-4" />
</button>
<button
type="button"
class="text-xs text-red-400/80 opacity-0 transition group-hover:opacity-100"
@click="removeEntry(f, 'file')"
>
<Trash2 class="h-4 w-4" />
</button>
</li>
<li v-if="!files.length" class="text-xs text-slate-500">No files</li>
</ul>
</div>
</div>
</div>
<div v-if="renameTarget" class="border-t border-slate-800 px-4 py-3">
<div class="flex flex-wrap items-center gap-2">
<input v-model="renameValue" class="field flex-1" @keyup.enter="confirmRename" />
<button type="button" class="button-primary" @click="confirmRename">Save</button>
<button type="button" class="button-secondary" @click="renameTarget = null">Cancel</button>
</div>
</div>
</div>
</div>
</div>
</template>
+67
View File
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { ref } from "vue";
import { api } from "@/api";
const emit = defineEmits<{ loggedIn: [] }>();
const username = ref("");
const password = ref("");
const err = ref("");
const busy = ref(false);
async function submit() {
err.value = "";
busy.value = true;
try {
await api.login(username.value.trim(), password.value);
emit("loggedIn");
} catch (e) {
err.value = e instanceof Error ? e.message : "Login failed";
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center px-6 py-10">
<div class="panel w-full max-w-md p-8">
<h1 class="font-sans text-2xl font-semibold tracking-tight text-white">
S3 Client
</h1>
<p class="mt-2 text-sm text-slate-400">
Sign in to manage accounts, buckets, and objects.
</p>
<form class="mt-6 space-y-4" @submit.prevent="submit">
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">
Username
</label>
<input
v-model="username"
type="text"
autocomplete="username"
class="field"
required
/>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">
Password
</label>
<input
v-model="password"
type="password"
autocomplete="current-password"
class="field"
required
/>
</div>
<p v-if="err" class="text-sm text-red-400">{{ err }}</p>
<button type="submit" :disabled="busy" class="button-primary w-full">
{{ busy ? "Signing in..." : "Sign in" }}
</button>
</form>
</div>
</div>
</template>
+14
View File
@@ -0,0 +1,14 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import "./style.css";
const app = createApp(App);
app.use(createPinia());
app.mount("#app");
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => {});
});
}
+43
View File
@@ -0,0 +1,43 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html.dark,
html {
color-scheme: dark;
}
body {
background: #0f1419;
}
.app-shell {
background:
radial-gradient(circle at top left, rgba(61, 154, 237, 0.16), transparent 38%),
radial-gradient(circle at 80% 10%, rgba(61, 154, 237, 0.08), transparent 24%),
#0f1419;
}
.panel {
@apply rounded-xl border border-slate-800 bg-surface-raised shadow-float;
}
.field {
@apply w-full rounded-lg border border-slate-700 bg-surface-overlay px-3 py-2 text-sm text-white outline-none ring-accent focus:border-accent focus:ring-1;
}
.button-primary {
@apply inline-flex items-center justify-center gap-2 rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-slate-950 transition hover:bg-sky-400 disabled:opacity-50;
}
.button-secondary {
@apply inline-flex items-center justify-center gap-2 rounded-lg border border-slate-700 bg-surface-overlay px-4 py-2 text-sm font-medium text-slate-200 transition hover:border-slate-500;
}
.chip {
@apply inline-flex items-center gap-2 rounded-full border border-slate-700 bg-surface-overlay px-3 py-1 text-xs text-slate-300;
}
.tab-shell {
@apply rounded-lg border border-slate-800 bg-surface-raised shadow-float;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+27
View File
@@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
surface: {
DEFAULT: "#0f1419",
raised: "#151c24",
overlay: "#1a232e",
},
accent: {
DEFAULT: "#3d9aed",
muted: "#2a6fa3",
},
},
fontFamily: {
sans: ["IBM Plex Sans", "system-ui", "sans-serif"],
mono: ["IBM Plex Mono", "ui-monospace", "monospace"],
},
boxShadow: {
float: "0 12px 30px rgba(0, 0, 0, 0.35)",
},
},
},
plugins: [],
};
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler"
},
"include": ["vite.config.ts"]
}
+52
View File
@@ -0,0 +1,52 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig, type Plugin } from "vite";
import vue from "@vitejs/plugin-vue";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const staticRoot = path.resolve(__dirname, "../static");
function servePwaFromStatic(): Plugin {
return {
name: "serve-pwa-from-static",
configureServer(server) {
server.middlewares.use((req, res, next) => {
const url = req.url?.split("?")[0] ?? "";
if (url !== "/manifest.webmanifest" && url !== "/sw.js") {
next();
return;
}
const name = url.slice(1);
const filePath = path.join(staticRoot, name);
if (!fs.existsSync(filePath)) {
next();
return;
}
const body = fs.readFileSync(filePath);
const type = name.endsWith(".webmanifest")
? "application/manifest+json"
: "application/javascript";
res.setHeader("Content-Type", type);
res.end(body);
});
},
};
}
export default defineConfig({
plugins: [vue(), servePwaFromStatic()],
resolve: {
alias: { "@": path.resolve(__dirname, "src") },
},
build: {
outDir: "../static/dist",
emptyOutDir: true,
},
server: {
port: 5173,
proxy: {
"/api": "http://127.0.0.1:5000",
},
},
});
+5
View File
@@ -0,0 +1,5 @@
flask
mysql-connector-python
python-dotenv
boto3
gunicorn
+24
View File
@@ -0,0 +1,24 @@
{
"name": "S3 Client",
"short_name": "S3",
"description": "Web-based S3 client",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#334155",
"theme_color": "#334155",
"icons": [
{
"src": "https://assets.jdbnet.co.uk/projects/s3_maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "https://assets.jdbnet.co.uk/projects/s3_maskable.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+7
View File
@@ -0,0 +1,7 @@
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});