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