878 lines
29 KiB
Python
878 lines
29 KiB
Python
"""
|
|
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"),
|
|
)
|