feat: ✨ initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
FROM mcr.microsoft.com/devcontainers/python:3.14
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
CMD ["sleep", "infinity"]
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.devcontainer
|
||||
.git
|
||||
.gitea
|
||||
__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
frontend/node_modules
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
.env
|
||||
frontend/node_modules/
|
||||
static/dist/
|
||||
+19
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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>
|
||||
Generated
+2473
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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(() => {});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
flask
|
||||
mysql-connector-python
|
||||
python-dotenv
|
||||
boto3
|
||||
gunicorn
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
Reference in New Issue
Block a user