feat: ✨ initial commit
This commit is contained in:
@@ -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"),
|
||||
)
|
||||
Reference in New Issue
Block a user