diff --git a/README.md b/README.md index 4446a3c..68022dc 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ S3 Web Browser is a Flask-based web application that allows users to browse AWS - **List S3 Buckets**: View all S3 buckets available to the AWS account. - **Browse Bucket Contents**: Navigate through the contents of any S3 bucket, including folders and files. +- **Search Bucket Contents**: Search for files/folders in any S3 bucket, The search will be recursive from the point of origin. - **Generate Presigned URLs**: Securely generate temporary URLs for S3 objects, making them accessible for a limited time. ## Run diff --git a/app.py b/app.py index dd4866a..9fd4269 100644 --- a/app.py +++ b/app.py @@ -1,9 +1,10 @@ +import dataclasses import os import boto3 import botocore import humanize -from flask import Flask, render_template +from flask import Flask, render_template, request app = Flask(__name__) app.secret_key = "your_secure_random_key_here" # noqa: S105 @@ -26,24 +27,117 @@ @app.route("/", methods=["GET"]) def index() -> str: s3 = boto3.resource("s3", **AWS_KWARGS) - buckets = s3.buckets.all() - return render_template("index.html", buckets=buckets) + all_buckets = s3.buckets.all() + return render_template("index.html", buckets=all_buckets) @app.route("/buckets") def buckets() -> str: s3 = boto3.resource("s3", **AWS_KWARGS) - buckets = s3.buckets.all() - return render_template("index.html", buckets=buckets) + all_buckets = s3.buckets.all() + return render_template("index.html", buckets=all_buckets) + + +@dataclasses.dataclass(eq=True, frozen=True) +class S3Entry: + """Representation of S3 object.""" + + name: str + type: str + url: str = "" + size: str = "" + date_modified: str = "" + + +def parse_responses(responses: list, s3_client: botocore.client.BaseClient, bucket_name: str, search_param: str) -> list[S3Entry]: + contents: set[S3Entry] = set() + for response in responses: + # Add folders to contents + if "CommonPrefixes" in response: + for item in response["CommonPrefixes"]: + contents.add(S3Entry(name=item["Prefix"], type="folder")) + + # Add files to contents + if "Contents" in response: + for item in response["Contents"]: + if not item["Key"].endswith("/"): + url = s3_client.generate_presigned_url( + "get_object", + Params={"Bucket": bucket_name, "Key": item["Key"]}, + ExpiresIn=3600, + ) # URL expires in 1 hour + contents.add( + S3Entry( + name=f'{bucket_name}/{item["Key"]}', + type="file", + url=url, + size=humanize.naturalsize(item["Size"]), + date_modified=item["LastModified"]) + ) + + contents_list = list(contents) + if search_param: + contents_list = list(filter(lambda x: search_param in x.name, contents_list)) + return sorted(contents_list, key=lambda x: x.type, reverse=True) + + +def list_objects(s3_client: botocore.client.BaseClient, bucket_name: str, path: str, delimiter: str = "") -> list[dict]: + responses = [] + list_params = {"Bucket": bucket_name, "Prefix": path} + if delimiter: + list_params["Delimiter"] = "/" + + while True: + response = s3_client.list_objects_v2(**list_params) + responses.append(response) + if response["IsTruncated"]: + list_params["ContinuationToken"] = response["NextContinuationToken"] + else: + break + + return responses + + +@app.route("/search//buckets/", defaults={"path": ""}) +@app.route("/search/buckets//") +def search_bucket(bucket_name: str, path: str) -> str: + s3_client = boto3.client("s3", **AWS_KWARGS) + responses = [] + try: + responses.extend(list_objects(s3_client, bucket_name, path)) + responses.extend(list_objects(s3_client, bucket_name, path, "/")) + except botocore.exceptions.ClientError as e: + match e.response["Error"]["Code"]: + case "AccessDenied": + return render_template( + "error.html", + error="You do not have permission to access this bucket.", + ) + case "NoSuchBucket": + return render_template("error.html", error="The specified bucket does not exist.") + case _: + return render_template("error.html", error=f"An unknown error occurred: {e}") + except Exception as e: # noqa: BLE001 + return render_template("error.html", error=f"An unknown error occurred: {e}") + + search_param = request.args.get("search", "") + contents = parse_responses(responses, s3_client, bucket_name, search_param) + return render_template( + "bucket_contents.html", + contents=contents, + bucket_name=bucket_name, + path=path, + search_param=search_param, + ) @app.route("/buckets/", defaults={"path": ""}) @app.route("/buckets//") def view_bucket(bucket_name: str, path: str) -> str: s3_client = boto3.client("s3", **AWS_KWARGS) - + responses = [] try: - response = s3_client.list_objects_v2(Bucket=bucket_name, Prefix=path, Delimiter="/") + responses.extend(list_objects(s3_client, bucket_name, path, "/")) except botocore.exceptions.ClientError as e: match e.response["Error"]["Code"]: case "AccessDenied": @@ -57,44 +151,15 @@ def view_bucket(bucket_name: str, path: str) -> str: return render_template("error.html", error=f"An unknown error occurred: {e}") except Exception as e: # noqa: BLE001 return render_template("error.html", error=f"An unknown error occurred: {e}") - contents = [] - - # Add folders to contents - if "CommonPrefixes" in response: - for item in response["CommonPrefixes"]: - contents.append( # noqa: PERF401 - { - "name": item["Prefix"], - "type": "folder", - "size": 0, - "date_modified": "", - } - ) - - # Add files to contents - if "Contents" in response: - for item in response["Contents"]: - if not item["Key"].endswith("/"): # Ignore directories - url = s3_client.generate_presigned_url( - "get_object", - Params={"Bucket": bucket_name, "Key": item["Key"]}, - ExpiresIn=3600, - ) # URL expires in 1 hour - contents.append( - { - "name": f'{bucket_name}/{item["Key"]}', - "type": "file", - "url": url, - "size": humanize.naturalsize(item["Size"]), - "date_modified": item["LastModified"], - } - ) + search_param = request.args.get("search", "") + contents = parse_responses(responses, s3_client, bucket_name, search_param) return render_template( "bucket_contents.html", contents=contents, bucket_name=bucket_name, path=path, + search_param=search_param, ) diff --git a/templates/bucket_contents.html b/templates/bucket_contents.html index 89972f9..77d480e 100644 --- a/templates/bucket_contents.html +++ b/templates/bucket_contents.html @@ -1,20 +1,25 @@ {% extends "base.html" %} {% block content %} +
+ + +

Contents of {{ bucket_name }}

Back to Buckets {% if path %} -Go Up {% endif %}