From db3b8d8d402cbb7ba562b96d28571f22452e8701 Mon Sep 17 00:00:00 2001 From: Henil Panchal <37398093+henilp105@users.noreply.github.com> Date: Sun, 4 Feb 2024 13:49:00 +0530 Subject: [PATCH 1/5] add: package ratings and malicious reports and logging (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: containers and build in 1 network only * feat: add download monitoring, with version,date, total * feat: add ratings functionality * feat: get ratings functionality and fix package verifications by github actions * feat: add tarball generation by github actions * feat: add package verification and archive generations in github actions * feat: add functionality for security and malicious report * feat: add report viewing and searching functionality * feat: add env variables * feat: add fn * feat: add fn * feat: add fn * feat: add fn * feat: fix mongodump * feat: fix mongodump * add docs * add: docs * add: docs * add: add monogbaseed error logger * add tests * fix: bug * clean * fix: support models and jwt tokens in latest APIs * fix: redundant checks * fix: switched to jwt * fix: switched to jwt * fix: bugs * fix: jwt tests * fix: jwt tests * fix to latest docker container * fix: mongodb * fix: remove network service and add required environment variables fo… · arteevraina/registry@9633648 --------- Co-authored-by: Arteev Raina --- .github/workflows/actions.yml | 2 +- .github/workflows/generate_tarballs.yml | 42 ++++ .github/workflows/security_checks.yml | 34 +++ .gitignore | 3 +- backend/app.py | 6 +- backend/compose.yaml | 20 +- backend/docker/backend.Dockerfile | 2 +- backend/docker/validate_package.Dockerfile | 14 +- backend/documentation/post_malicious.yaml | 68 ++++++ backend/documentation/post_rating.yaml | 68 ++++++ backend/documentation/view_report.yaml | 35 +++ backend/generate_tarball.py | 32 +++ backend/models/package.py | 30 ++- backend/mongo.py | 36 +-- backend/packages.py | 243 +++++++++++++++++---- backend/server.py | 21 +- backend/tests/test_packages.py | 181 ++++++++++++++- backend/user.py | 6 +- backend/validate.py | 79 +++++++ backend/validate_package.py | 28 --- 20 files changed, 820 insertions(+), 130 deletions(-) create mode 100644 .github/workflows/generate_tarballs.yml create mode 100644 .github/workflows/security_checks.yml create mode 100644 backend/documentation/post_malicious.yaml create mode 100644 backend/documentation/post_rating.yaml create mode 100644 backend/documentation/view_report.yaml create mode 100644 backend/generate_tarball.py create mode 100755 backend/validate.py delete mode 100755 backend/validate_package.py diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 9a8e81fb..b9be9a64 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout page source - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Build the Docker image and run tests run: docker compose -f "backend/compose.test.yaml" up -d diff --git a/.github/workflows/generate_tarballs.yml b/.github/workflows/generate_tarballs.yml new file mode 100644 index 00000000..d8d69169 --- /dev/null +++ b/.github/workflows/generate_tarballs.yml @@ -0,0 +1,42 @@ +# Github action to generate of the registry database archives +# + +name: Registry Database Archive + +on: + workflow_dispatch: + schedule: + - cron: "0 0 1,15 * *" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: checkout source + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + + - name: Setup mongo tools - only shell + uses: boly38/action-mongo-tools@stable + with: + mongo-shell: "false" + mongo-tools: "true" + + - name: Install python libraries + run: pip3 install --user -r backend/requirements.txt + + - name: load env file + run: | + echo "${{ secrets.ENV_FILE }}" > backend/.env + + - name: generate tarball + run: cd backend && python generate_tarball.py + + - name: Archives + uses: actions/upload-artifact@v2 + with: + name: Registry Database Archive + path: backend/static/ diff --git a/.github/workflows/security_checks.yml b/.github/workflows/security_checks.yml new file mode 100644 index 00000000..14d92b76 --- /dev/null +++ b/.github/workflows/security_checks.yml @@ -0,0 +1,34 @@ +# Github action to build and verify the fortran packages +# + +name: Fortran Package Verification + +on: + workflow_dispatch: + schedule: + - cron: "0 2 * * *" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: checkout source + uses: actions/checkout@v4 + + - uses: fortran-lang/setup-fpm@v5 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Python + uses: actions/setup-python@v5 + + - name: Install python libraries + run: pip3 install --user -r backend/requirements.txt + + - name: load env file + run: | + echo "${{ secrets.ENV_FILE }}" > backend/.env + + - name: validate fortran packages + run: cd backend && python validate.py diff --git a/.gitignore b/.gitignore index fa3364fd..ff130012 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. /.vscode/ +/.vercel/ /.docker/ # backend @@ -17,7 +18,7 @@ # dependencies /frontend/node_modules -/frontend//.pnp +/frontend/.pnp /frontend/.pnp.js # testing diff --git a/backend/app.py b/backend/app.py index fcefdbb2..8eb33524 100644 --- a/backend/app.py +++ b/backend/app.py @@ -4,9 +4,9 @@ from flask_jwt_extended import JWTManager app = Flask(__name__) -app.config["JWT_SECRET_KEY"] = "fpm-registry-secret-key" #TODO: Please change this. -app.config["JWT_ACCESS_TOKEN_EXPIRES"] = 90 * 24 * 60 * 60 # 90 days -CORS(app) +app.config["JWT_SECRET_KEY"] = "fpm-registry-secret-key" #TODO: Please change this. +app.config["JWT_ACCESS_TOKEN_EXPIRES"] = 90 * 24 * 60 * 60 # 90 days +CORS(app) #TODO: Please restrict to only the frontend domain. JWTManager(app) swagger = Swagger( diff --git a/backend/compose.yaml b/backend/compose.yaml index 5b4f0ee7..97dfa6a9 100644 --- a/backend/compose.yaml +++ b/backend/compose.yaml @@ -19,19 +19,17 @@ services: # flask requires SIGINT to stop gracefully # (default stop signal from Compose is SIGTERM) stop_signal: SIGINT - environment: # these are the test environment variables. + environment: # these are the test environment variables. - FLASK_SERVER_PORT=9091 - - MONGO_DB_NAME=registry - - MONGO_URI=mongodb://mongo:27017/fpmregistry + - MONGO_URI=mongodb://mongo:27017/ - SALT=MYSALT - MONGO_DB_NAME=fpmregistry - SUDO_PASSWORD=fortran - MONGO_USER_NAME=fortran - MONGO_PASSWORD=fortran - HOST=localhost:3000 - # - RESET_EMAIL=reset@localhost.com # set to registry email - # - RESET_PASSWORD=reset - + - RESET_EMAIL=reset@localhost.com # set to registry email + - RESET_PASSWORD=reset volumes: - .:/src depends_on: @@ -44,6 +42,14 @@ services: stop_signal: SIGINT volumes: - .:/src + ports: + - "5001:5001" + environment: # these are the test environment variables. + - MONGO_URI=mongodb://mongo:27017/ + - SALT=MYSALT + - MONGO_DB_NAME=fpmregistry + - MONGO_USER_NAME=fortran + - MONGO_PASSWORD=fortran mongo: - image: mongo + image: mongo \ No newline at end of file diff --git a/backend/docker/backend.Dockerfile b/backend/docker/backend.Dockerfile index c7ccbc6f..5f5b5ced 100755 --- a/backend/docker/backend.Dockerfile +++ b/backend/docker/backend.Dockerfile @@ -1,5 +1,5 @@ # Prod environment -FROM --platform=$BUILDPLATFORM python:3.10-alpine AS builder +FROM --platform=$BUILDPLATFORM python:3.10.13-bookworm AS builder WORKDIR /src COPY requirements.txt /src diff --git a/backend/docker/validate_package.Dockerfile b/backend/docker/validate_package.Dockerfile index b61a1737..7783555a 100644 --- a/backend/docker/validate_package.Dockerfile +++ b/backend/docker/validate_package.Dockerfile @@ -1,6 +1,5 @@ -FROM alpine:latest +FROM --platform=$BUILDPLATFORM python:3.10-alpine AS builder -# Install system dependencies RUN apk add --no-cache \ bash \ bash-completion \ @@ -18,4 +17,13 @@ WORKDIR /home/registry RUN wget https://github.com/fortran-lang/fpm/releases/download/v0.9.0/fpm-0.9.0-linux-x86_64 -4 -O fpm && \ chmod u+x fpm -WORKDIR /home/registry + +WORKDIR /src +COPY requirements.txt /src +RUN --mount=type=cache,target=/root/.cache/pip \ + pip3 install -r requirements.txt + +COPY . . + + +CMD ["python3", "validate.py"] \ No newline at end of file diff --git a/backend/documentation/post_malicious.yaml b/backend/documentation/post_malicious.yaml new file mode 100644 index 00000000..3c6fca6f --- /dev/null +++ b/backend/documentation/post_malicious.yaml @@ -0,0 +1,68 @@ +description: stores the malicious reports from the user for packages. +parameters: + - name: uuid + in: formData + description: The uuid of the user + required: true + type: string + - name: reason + in: formData + description: reason for reporting the package. + required: true + type: string + - name: namespace + in: url + description: namespace of the package to be reported + required: true + type: string + - name: package + in: url + description: package to be reported + required: true + type: string + +responses: + 200: + description: Malicious Report Submitted Successfully or Malicious Report Updated Successfully + schema: + type: object + properties: + code: + type: string + description: Response status code + message: + type: string + description: Malicious Report Submitted Successfully or Malicious Report Updated Successfully + 404: + description: User not found + schema: + type: object + properties: + code: + type: string + description: Response status code + message: + type: string + description: User not found or Namespace not found or Package not found + 400: + description: User not found + schema: + type: object + properties: + code: + type: string + description: Response status code + message: + type: string + description: Reason should atleast be 10 characters or Reason is missing + 401: + description: Unauthorized + schema: + type: object + properties: + code: + type: string + description: Response status code + message: + type: string + description: Error message diff --git a/backend/documentation/post_rating.yaml b/backend/documentation/post_rating.yaml new file mode 100644 index 00000000..03c97329 --- /dev/null +++ b/backend/documentation/post_rating.yaml @@ -0,0 +1,68 @@ +description: stores the ratings from the user for packages. +parameters: + - name: uuid + in: formData + description: The uuid of the user + required: true + type: string + - name: rating + in: formData + description: ratings for the package. + required: true + type: integer + - name: namespace + in: url + description: namespace of the package + required: true + type: string + - name: package + in: url + description: package to be rated + required: true + type: string + +responses: + 200: + description: Ratings Submitted Successfully or Ratings Updated Successfully + schema: + type: object + properties: + code: + type: string + description: Response status code + message: + type: string + description: Ratings Submitted Successfully or Ratings Updated Successfully + 404: + description: User not found + schema: + type: object + properties: + code: + type: string + description: Response status code + message: + type: string + description: User not found or Namespace not found or Package not found + 400: + description: Rating is missing + schema: + type: object + properties: + code: + type: string + description: Response status code + message: + type: string + description: Rating is missing or Rating should be between 1 and 5 + 401: + description: Unauthorized + schema: + type: object + properties: + code: + type: string + description: Response status code + message: + type: string + description: Error message diff --git a/backend/documentation/view_report.yaml b/backend/documentation/view_report.yaml new file mode 100644 index 00000000..50837a1b --- /dev/null +++ b/backend/documentation/view_report.yaml @@ -0,0 +1,35 @@ +description: Shows the malicious reports of the various packages to the Admin only +parameters: + - name: uuid + in: formData + description: The uuid of the user + required: true + type: string + + +responses: + 200: + description: Malicious Reports fetched Successfully + schema: + type: object + properties: + code: + type: string + description: Response status code + message: + type: string + description: Response message + reports: + type: list + description: objects of malicious reports with thier packages,namespaces,reporters. + 401: + description: Unauthorized + schema: + type: object + properties: + code: + type: string + description: Response status code + message: + type: string + description: Unauthorized diff --git a/backend/generate_tarball.py b/backend/generate_tarball.py new file mode 100644 index 00000000..5e392040 --- /dev/null +++ b/backend/generate_tarball.py @@ -0,0 +1,32 @@ +import os +from datetime import datetime, timedelta +from pymongo import MongoClient +from dotenv import load_dotenv +from gridfs import GridFS +from app import app +from flask import jsonify +import subprocess + +load_dotenv() +database_name = os.environ["MONGO_DB_NAME"] +try: + mongo_uri = os.environ["MONGO_URI"] + mongo_username = os.environ["MONGO_USER_NAME"] + mongo_password = os.environ["MONGO_PASSWORD"] + client = MongoClient(mongo_uri) +except KeyError as err: + print("Add MONGO_URI to .env file") + +def generate_latest_tarball(): + # Execute the mongodump command + archive_date = datetime.now().strftime("%d-%m-%Y") + archive_path = os.path.abspath(f"static/registry-{archive_date}.tar.gz") + command = f"mongodump --uri={mongo_uri} --archive={archive_path} --db={database_name} --gzip --excludeCollection=users".split() + try: + subprocess.call(command, text=True) + except subprocess.CalledProcessError as e: + print(f"Failed: {e}") + exit(1) + print("Database backup created successfully") + +generate_latest_tarball() \ No newline at end of file diff --git a/backend/models/package.py b/backend/models/package.py index 63dd0c62..d37fb377 100644 --- a/backend/models/package.py +++ b/backend/models/package.py @@ -16,6 +16,15 @@ def __init__(self, name, namespace, description, homepage, repository, self.tags = tags self.is_deprecated = is_deprecated self.versions = versions + self.malicious_report = {} + self.is_verified = False + self.is_malicious = False + self.security_status = "No security issues found" + self.downloads_stats = {} + self.ratings = { + "users": {}, + "avg_ratings": 0, + } # Ensure that versions list only contains instances of Version class for v in self.versions: @@ -46,7 +55,12 @@ def to_json(self): "maintainers": maintainers_json, "tags": self.tags, "is_deprecated": self.is_deprecated, - "versions": versions_json + "versions": versions_json, + "malicious_report": self.malicious_report, + "is_verified": self.is_verified, + "is_malicious": self.is_malicious, + "security_status": self.security_status, + "ratings": self.ratings, } # Create a from_json method. @@ -73,7 +87,12 @@ def from_json(json_data): maintainers=json_data.get("maintainers"), tags=json_data.get("tags"), is_deprecated=json_data.get("is_deprecated"), - versions=versions + versions=versions, + malicious_report=json_data.get("malicious_report"), + is_verified=json_data.get("is_verified"), + is_malicious=json_data.get("is_malicious"), + security_status=json_data.get("security_status"), + ratings=json_data.get("ratings"), ) class Version: @@ -84,6 +103,7 @@ def __init__(self, version, tarball, dependencies, created_at, is_deprecated, do self.created_at = created_at self.is_deprecated = is_deprecated self.download_url = download_url + self.is_verified = False # Create a to_json method. def to_json(self): @@ -93,7 +113,8 @@ def to_json(self): "dependencies": self.dependencies, "created_at": self.created_at, "is_deprecated": self.is_deprecated, - "download_url": self.download_url + "download_url": self.download_url, + "is_verified": self.is_verified, } # Create a from_json method. @@ -105,5 +126,6 @@ def from_json(json_data): dependencies=json_data.get("dependencies"), created_at=json_data.get("created_at"), is_deprecated=json_data.get("is_deprecated"), - download_url=json_data.get("download_url") + download_url=json_data.get("download_url"), + is_verified=json_data.get("is_verified"), ) \ No newline at end of file diff --git a/backend/mongo.py b/backend/mongo.py index 501529b0..1ef6abf4 100644 --- a/backend/mongo.py +++ b/backend/mongo.py @@ -5,7 +5,7 @@ from gridfs import GridFS from app import app from flask import jsonify -import subprocess +import logging load_dotenv() database_name = os.environ["MONGO_DB_NAME"] @@ -20,25 +20,31 @@ db = client[database_name] file_storage = GridFS(db, collection="tarballs") +# Create a collection to store the logs +class MongoDBHandler(logging.Handler): + def __init__(self, collection): + super().__init__() + self.collection = collection + + def emit(self, record): + log_document = { + "timestamp": datetime.utcnow(), + "level": record.levelname, + "message": self.format(record) + } + self.collection.insert_one(log_document) + +# Create the MongoDB logging handler +mongo_handler = MongoDBHandler(collection=db.logs) +mongo_handler.setLevel(logging.ERROR) # Set the handler's log level to the lowest (DEBUG) + +# Configure the root logger with the MongoDB handler +logging.root.addHandler(mongo_handler) @app.route("/registry/archives", methods=["GET"]) def clone(): folder_path = "static" file_list = os.listdir(folder_path) - - # Check if the folder exists and was modified more than 1 week ago - if os.path.exists(folder_path): - mod_time = datetime.fromtimestamp(os.path.getmtime(folder_path)) - if datetime.now() - mod_time > timedelta(days=7): - generate_latest_tarball() - return jsonify( {"message": "Successfully Fetched Archives", "archives": file_list, "code": 200} ) - - -def generate_latest_tarball(): - # Execute the mongodump command - archive_date = datetime.datetime.now().strftime("%Y-%m-%d") - command = f"mongodump --uri={mongo_uri}--archive=static/registry-{archive_date}.tar.gz --db={database_name} --gzip --excludeCollection=users" - subprocess.call(command, shell=True) \ No newline at end of file diff --git a/backend/packages.py b/backend/packages.py index 9c656850..960165b0 100644 --- a/backend/packages.py +++ b/backend/packages.py @@ -5,12 +5,13 @@ from flask import request, jsonify, abort, send_file from gridfs.errors import NoFile from datetime import datetime, timedelta -from auth import generate_uuid,IS_VERCEL +from auth import generate_uuid, IS_VERCEL from app import swagger import tarfile import os import toml import shutil +import json from flasgger.utils import swag_from from urllib.parse import unquote import math @@ -21,7 +22,6 @@ from models.user import User from models.package import Package from models.package import Version -# from validate_package import validate parameters = { @@ -167,6 +167,7 @@ def upload(): package_name = request.form.get("package_name") package_version = request.form.get("package_version") package_license = request.form.get("package_license") + homepage = request.form.get("homepage") dry_run = request.form.get("dry_run") tarball = request.files["tarball"] @@ -276,23 +277,25 @@ def upload(): {"name": package_name, "namespace": namespace_obj.id} ) - if tarball.content_type not in ["application/gzip", "application/zip","application/octet-stream","application/x-tar"]: + if tarball.content_type not in [ + "application/gzip", + "application/zip", + "application/octet-stream", + "application/x-tar", + ]: return jsonify({"code": 400, "message": "Invalid file type"}), 400 tarball_name = "{}-{}.tar.gz".format(package_name, package_version) - if not IS_VERCEL: # TODO: Disable this after Validation is Enabled - package_data = extract_toml(tarball) - else: - package_data = {"homepage": "homepage", "repository": "repository", "description": "description","copyright":"copyright"} - # validate the package with fpm - # valid_package, package_data = validate(tarball,"{}-{}".format(package_name, package_version)) # TODO: Enable this after Validation is Enabled - - - # if not valid_package: # TODO: Enable this after Validation is Enabled - # return jsonify({"code": 400, "message": "Invalid package"}), 400 + package_data = { + "repository": "Package Under Verification", + "description": "Package Under Verification", + "copyright": "Package Under Verification", + } - file_object_id = file_storage.put(tarball, content_type=tarball.content_type, filename=tarball_name) + file_object_id = file_storage.put( + tarball, content_type=tarball.content_type, filename=tarball_name + ) # No previous recorded versions of the package found. if not package_doc: @@ -301,7 +304,7 @@ def upload(): name=package_name, namespace=namespace_obj.id, description=package_data["description"], - homepage=package_data["homepage"], + homepage=homepage, repository=package_data["repository"], copyright=package_data["copyright"], license=package_license, @@ -377,10 +380,9 @@ def upload(): created_at=datetime.utcnow(), is_deprecated=False, download_url=f"/tarballs/{file_object_id}", - # "download_url_zip": f"/tarballs/{zipfile_object_id}", - # "download_url_tar": f"/tarballs/{tarfile_object_id}", ) + package_obj.versions.append(new_version) package_obj.versions = sorted( @@ -431,22 +433,40 @@ def check_token_expiry(upload_token_created_at): return False -@app.route('/tarballs/', methods=["GET"]) + +@app.route("/tarballs/", methods=["GET"]) @swag_from("documentation/get_tarball.yaml", methods=["GET"]) def serve_gridfs_file(oid): try: file = file_storage.get(ObjectId(oid)) - # Return the file data as a Flask response object - return send_file( - file, - download_name=file.filename, - as_attachment=True, - mimetype=file.content_type, + package_version_doc = db.packages.update_one( + { + "versions.oid": oid, + }, + { + "$inc": { + f"downloads_stats.versions.{oid}": 1, + "downloads_stats.total_downloads": 1, + f"downloads_stats.dates.{str(datetime.now())[:10]}.{oid}": 1, + f"downloads_stats.dates.{str(datetime.now())[:10]}.total_downloads": 1, + } + }, ) + if package_version_doc.modified_count > 0: + # Return the file data as a Flask response object + return send_file( + file, + download_name=file.filename, + as_attachment=True, + mimetype=file.content_type, + ) + return jsonify({"message": "Package version not found", "code": 404}), 404 + except NoFile: abort(404) + def check_version(current_version, new_version): current_list = list(map(int, current_version.split("."))) new_list = list(map(int, new_version.split("."))) @@ -494,11 +514,13 @@ def get_package(namespace_name, package_name): "version_history": package_obj.to_json()["versions"], "updated_at": package_obj.updated_at, "description": package_obj.description, + "ratings": round(sum(package_obj.ratings['users'].values())/len(package_obj.ratings['users']),3), + "downloads": package_obj.downloads_stats, } return jsonify({"data": package_response_data, "code": 200}) - + @app.route("/packages///verify", methods=["POST"]) @swag_from("documentation/verify_user_role.yaml", methods=["POST"]) @jwt_required() @@ -568,7 +590,9 @@ def get_package_from_version(namespace_name, package_name, version): return jsonify({"message": "Package not found", "code": 404}), 404 else: - package_obj = Package.from_json(package) + return jsonify({"message": f"Package not found {type(json.dumps(package))}", "code": 404,}), 404 + package_obj = Package.from_json(json.dumps(package)) + return jsonify({"message": f"Package not found {package}", "code": 404,}), 404 # Get the package author from id. package_author = db.users.find_one({"_id": package_obj.author}) @@ -767,6 +791,7 @@ def create_token_upload_token_package(namespace_name, package_name): 200, ) + @app.route("/packages///maintainers", methods=["GET"]) @swag_from("documentation/package_maintainers.yaml", methods=["GET"]) @jwt_required() @@ -836,19 +861,153 @@ def checkUserUnauthorizedForNamespaceTokenCreation(user_id, namespace_obj): return str_user_id not in admins_id_list and str_user_id not in maintainers_id_list -def extract_toml(file): - with open('static/temp/temp.tar.gz', 'wb') as f: - f.write(file.read()) - with tarfile.open('static/temp/temp.tar.gz', "r") as tar: - tar.extractall("static/temp") - - for root, dirs, files in os.walk('static/temp'): - file_name = 'fpm.toml' - if file_name in files: - file_path = os.path.join(root, file_name) - with open(file_path, 'r') as file: - file_content = file.read() - shutil.rmtree('static/temp') - os.makedirs('static/temp', exist_ok=True) - parsed_toml = toml.loads(file_content) - return parsed_toml \ No newline at end of file +@app.route("/ratings//", methods=["POST"]) +@swag_from("documentation/post_rating.yaml", methods=["POST"]) +@jwt_required() +def post_ratings(namespace, package): + uuid = get_jwt_identity() + rating = request.form.get("rating") + + if not rating: + return jsonify({"code": 400, "message": "Rating is missing"}), 400 + + if int(rating) < 1 or int(rating) > 5: + return ( + jsonify({"code": 400, "message": "Rating should be between 1 and 5"}), + 400, + ) + + user = db.users.find_one({"uuid": uuid}) + namespace_doc = db.namespaces.find_one({"namespace": namespace}) + package_doc = db.packages.find_one( + {"name": package, "namespace": namespace_doc["_id"]} + ) + + if not user or not namespace_doc or not package_doc: + error_message = { + "user": "User not found" if not user else None, + "namespace": "Namespace not found" if not namespace_doc else None, + "package": "Package not found" if not package_doc else None, + "code": 404 + } + return jsonify({"message": error_message}), 404 + + if user["_id"] in package_doc["ratings"]["users"] and package_doc["ratings"][ + "users" + ][user["_id"]] == int(rating): + return jsonify({"message": "Ratings Submitted Successfully", "code": 200}), 200 + + if user["_id"] in package_doc["ratings"]["users"] and package_doc["ratings"][ + "users" + ][user["_id"]] != int(rating): + package_version_doc = db.packages.update_one( + {"name": package, "namespace": namespace_doc["_id"]}, + { + "$set": { + f"ratings.users.{user['_id']}": int(rating), + }, + }, + ) + return jsonify({"message": "Ratings Updated Successfully", "code": 200}), 200 + + package_version_doc = db.packages.update_one( + {"name": package, "namespace": namespace_doc["_id"]}, + { + "$set": { + f"ratings.users.{user['_id']}": int(rating), + }, + "$inc": { + "ratings.total_count": 1, + }, + }, + ) + return jsonify({"message": "Ratings Submitted Successfully", "code": 200}), 200 + + +@app.route("/report//", methods=["POST"]) +@swag_from("documentation/post_malicious.yaml", methods=["POST"]) +@jwt_required() +def post_malicious(namespace, package): + uuid = get_jwt_identity() + reason = request.form.get("reason") + + if not reason: + return jsonify({"code": 400, "message": "Reason is missing"}), 400 + + reason = reason.strip() + + if len(reason) < 10: + return ( + jsonify({"code": 400, "message": "Reason should atleast be 10 characters"}), + 400, + ) + + user = db.users.find_one({"uuid": uuid}) + namespace_doc = db.namespaces.find_one({"namespace": namespace}) + package_doc = db.packages.find_one( + {"name": package, "namespace": namespace_doc["_id"]} + ) + + if not user or not namespace_doc or not package_doc: + error_message = { + "user": "User not found" if not user else None, + "namespace": "Namespace not found" if not namespace_doc else None, + "package": "Package not found" if not package_doc else None, + "code": 404 + } + return jsonify({"message": error_message}), 404 + + if user["_id"] in package_doc["malicious_report"]["users"] and package_doc["malicious_report"][ + "users" + ][user["_id"]]['reason'] == str(reason): + return jsonify({"message": "Malicious Report Submitted Successfully", "code": 200}), 200 + + if user["_id"] in package_doc["malicious_report"]["users"] and package_doc["malicious_report"][ + "users" + ][user["_id"]]['reason'] != str(reason): + package_version_doc = db.packages.update_one( + {"name": package, "namespace": namespace_doc["_id"]}, + { + "$set": { + f"malicious_report.users.{user['_id']}": { 'reason': str(reason), 'isViewed': False }, + "malicious_report.isViewed": False, + + }, + }, + ) + return jsonify({"message": "Malicious Report Updated Successfully", "code": 200}), 200 + + package_version_doc = db.packages.update_one( + {"name": package, "namespace": namespace_doc["_id"]}, + { + "$set": { + f"malicious_report.users.{user['_id']}": { 'reason': str(reason), 'isViewed': False }, + "malicious_report.isViewed": False, + } + }, + ) + return jsonify({"message": "Malicious Report Submitted Successfully", "code": 200}), 200 + +@app.route("/report/view", methods=["GET"]) +@swag_from("documentation/view_report.yaml", methods=["GET"]) +@jwt_required() +def view_report(): + uuid = get_jwt_identity() + + user = db.users.find_one({"uuid": uuid}) + + if "admin" in user["roles"]: + non_viewed_reports = list() + malicious_reports = db.packages.find({"malicious_reports.isViewed": False}) + for package in list(malicious_reports): + for user_id, report in package.get("malicious_report", {}).get("users", {}).items(): + if not report.get("isViewed", False): + report['name'] = db.users.find_one({"_id": ObjectId(user_id)}, {"username": 1})["username"] + del report["isViewed"] + non_viewed_reports.append(report) + + return jsonify({"message": "Malicious Reports fetched Successfully", "code": 200, "reports": non_viewed_reports}), 200 + + + return jsonify({"message": "Unauthorized", "code": 401}), 401 + diff --git a/backend/server.py b/backend/server.py index 35f6dd7f..f8ec7a73 100755 --- a/backend/server.py +++ b/backend/server.py @@ -1,20 +1,11 @@ import os -from flask import render_template, jsonify +from flask import jsonify from app import app -from mongo import db from auth import is_ci -import logging import auth import user import packages import namespaces -# import validate_package # TODO: Uncomment this when the package validation is enabled - -logging.basicConfig( - filename="app.log", - level=logging.ERROR, - format="%(asctime)s [%(levelname)s] %(message)s", -) @app.route("/") @@ -27,19 +18,9 @@ def page_not_found(e): @app.errorhandler(500) def internal_server_error(e): - logging.error("Server Error: %s", str(e)) return jsonify({"message": "Internal server error", "code": 500}) -# Log all unhandled exceptions -def log_exception(sender, exception, **extra): - sender.logger.error( - "An exception occurred: %s", str(exception), exc_info=(exception) - ) - - -app.register_error_handler(Exception, log_exception) - debug = True if is_ci != "true" else False if __name__ == "__main__": diff --git a/backend/tests/test_packages.py b/backend/tests/test_packages.py index a9114df6..35ee39e1 100644 --- a/backend/tests/test_packages.py +++ b/backend/tests/test_packages.py @@ -4,6 +4,10 @@ from packages import check_token_expiry from datetime import datetime import random +import os +from dotenv import load_dotenv + +load_dotenv() class TestPackages(BaseTestClass): @@ -23,6 +27,7 @@ def setUp(self): "package_name": "test_package", "package_version": "0.0.1", "package_license": "MIT", + "homepage":"fortran-lang.org" } self.test_namespace_data = { @@ -38,7 +43,7 @@ def login(self): None Returns: - uuid (str): The UUID of the user who successfully logged in. + access_token (str): The access_token of the user who successfully logged in. Raises: AssertionError: If the response code received from the server is not as expected. @@ -53,6 +58,8 @@ def login(self): return self.access_token response_for_signup = self.client.post("/auth/signup", data=signup_data) + self.assertEqual(200, response_for_signup.json["code"]) + self.is_created = True login_data = {"user_identifier": self.email, "password": self.password} # Login with the same user. @@ -313,6 +320,7 @@ def test_get_existing_package_version(self): response = self.client.get( f"/packages/{self.test_namespace_data['namespace']}/{self.test_package_data['package_name']}/0.0.1" ) + print(response.json["message"]) self.assertEqual(200, response.json["code"]) print("test_get_existing_package_version passed") @@ -386,3 +394,174 @@ def test_package_maintainers(self): ) self.assertEqual(200, response.json["code"]) print("test_package_maintainers passed") + + def test_successful_rating_submit(self): + """ + Test case to verify the behaviour of the system when a user tries to submit rating to a package successfully. + + Parameters: + None + + Returns: + None + + Raises: + AssertionError: If the response code received from the server is not as expected. + """ + access_token = self.login() + response = self.client.post( + f"/ratings/{self.test_namespace_data['namespace']}/{self.test_package_data['package_name']}", + content_type="multipart/form-data", + data={"rating":5}, headers={"Authorization": f"Bearer {access_token}"}, + ) + self.assertEqual(200, response.json["code"]) + print("test_successful_rating_submit passed") + + def test_unsuccessful_rating_invalid_submit(self): + """ + Test case to verify the behaviour of the system when a user tries to submit invalid rating to a package successfully. + + Parameters: + None + + Returns: + None + + Raises: + AssertionError: If the response code received from the server is not as expected. + """ + access_token = self.login() + response = self.client.post( + f"/ratings/{self.test_namespace_data['namespace']}/{self.test_package_data['package_name']}", + content_type="multipart/form-data", + data={"rating":-1}, headers={"Authorization": f"Bearer {access_token}"}, + ) + self.assertEqual(400, response.json["code"]) + print("test_unsuccessful_rating_invalid_submit passed") + + def test_unsuccessful_rating_invalid_access_token_submit(self): + """ + Test case to verify the behaviour of the system when a user tries to submit valid rating to a package successfully with invalid access_token + + Parameters: + None + + Returns: + None + + Raises: + AssertionError: If the response code received from the server is not as expected. + """ + access_token = self.login() + response = self.client.post( + f"/ratings/{self.test_namespace_data['namespace']}/{self.test_package_data['package_name']}", + content_type="multipart/form-data", + data={"rating":5}, headers={"Authorization": f"Bearer access_token"}, + ) + self.assertEqual("Not enough segments", response.json["msg"]) + print("test_unsuccessful_rating_invalid_access_token_submit passed") + + def test_successful_post_malicious(self): + """ + Test case to verify the behaviour of the system when a user tries to submit malicious report to a package successfully + + Parameters: + None + + Returns: + None + + Raises: + AssertionError: If the response code received from the server is not as expected. + """ + access_token = self.login() + response = self.client.post( + f"/report/{self.test_namespace_data['namespace']}/{self.test_package_data['package_name']}", + content_type="multipart/form-data", + data={"reason":"the package is found to be malicious"}, headers={"Authorization": f"Bearer {access_token}"}, + ) + print("test_successful_post_malicious response", response.json) + self.assertEqual(200, response.json["code"]) + print("test_successful_post_malicious passed") + + def test_unsuccessful_post_malicious_invalid_access_token(self): + """ + Test case to verify the behaviour of the system when a user tries to submit malicious report to a package successfully with invalid access_token + + Parameters: + None + + Returns: + None + + Raises: + AssertionError: If the response code received from the server is not as expected. + """ + access_token = self.login() + response = self.client.post( + f"/report/{self.test_namespace_data['namespace']}/{self.test_package_data['package_name']}", + content_type="multipart/form-data", + data={"reason":"the package is found to be malicious"}, headers={"Authorization": f"Bearer access_token"}, + ) + self.assertEqual("Not enough segments", response.json["msg"]) + print("test_unsuccessful_post_malicious_invalid_access_token passed") + + def test_unsuccessful_post_malicious_short_reason(self): + """ + Test case to verify the behaviour of the system when a user tries to submit malicious report to a package successfully with invalid reason + + Parameters: + None + + Returns: + None + + Raises: + AssertionError: If the response code received from the server is not as expected. + """ + access_token = self.login() + response = self.client.post( + f"/report/{self.test_namespace_data['namespace']}/{self.test_package_data['package_name']}", + content_type="multipart/form-data", + data={"reason":"package"}, headers={"Authorization": f"Bearer {access_token}"}, + ) + self.assertEqual(400, response.json["code"]) + print("test_unsuccessful_post_malicious_short_reason passed") + + def test_successful_fetch_malicious_reports(self): + """ + Test case to verify the behaviour of the system when a sudo user tries fetch the malicious reports successfully + + Parameters: + None + + Returns: + None + + Raises: + AssertionError: If the response code received from the server is not as expected. + """ + + access_token = self.login() # create a sudo user + response = self.client.get("/report/view",headers={"Authorization": f"Bearer {access_token}"}) + self.assertEqual(200, response.json["code"]) + print("test_successful_fetch_malicious_reports passed") + + def test_unsuccessful_fetch_malicious_reports(self): + """ + Test case to verify the behaviour of the system when a sudo user tries fetch the malicious reports successfully with invalid access_token + + Parameters: + None + + Returns: + None + + Raises: + AssertionError: If the response code received from the server is not as expected. + """ + + access_token = self.login() + response = self.client.get("/report/view",headers={"Authorization": f"Bearer {access_token}"}) + self.assertEqual(401, response.json["code"]) + print("test_unsuccessful_fetch_malicious_reports passed") diff --git a/backend/user.py b/backend/user.py index fd68e0f9..07c51ef6 100644 --- a/backend/user.py +++ b/backend/user.py @@ -165,11 +165,9 @@ def delete_user(): if delete_user: db.users.delete_one({"username": username}) return jsonify({"message": "User deleted", "code": 200}), 200 - else: - return jsonify({"message": "User not found", "code": 404}), 404 + return jsonify({"message": "User not found", "code": 404}), 404 - else: - return jsonify({"message": "Unauthorized", "code": 401}), 401 + return jsonify({"message": "Unauthorized", "code": 401}), 401 @app.route("/users/account", methods=["POST"]) diff --git a/backend/validate.py b/backend/validate.py new file mode 100755 index 00000000..6fb67e8c --- /dev/null +++ b/backend/validate.py @@ -0,0 +1,79 @@ +from app import app +import subprocess +import toml +from mongo import db +from mongo import file_storage +from bson.objectid import ObjectId +from gridfs.errors import NoFile +import toml + + +def run_command(command): + result = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if result.returncode != 0: + print(f"Error executing command: {command}") + print(result.stderr) + return result.stdout if result.stdout else result.stderr + +def process_package(packagename): + # Create a temp directory + create_dir_command = f'mkdir -p static/temp/{packagename}' + run_command(create_dir_command) + # Extract the archive + extract_command = f'tar -xzf static/temp/{packagename}.tar.gz -C static/temp/{packagename}/' + run_command(extract_command) + + # Build the package + build_command = f'cd static/temp/{packagename} && fpm build' + result = run_command(build_command) + + # Read fpm.toml + toml_path = f'static/temp/{packagename}/fpm.toml' + try: + with open(toml_path, 'r') as file: + file_content = file.read() + parsed_toml = toml.loads(file_content) # handle toml parsing errors + except: + return False, None + + # Clean up + cleanup_command = f'rm -rf static/temp/{packagename} static/temp/{packagename}.tar.gz' + run_command(cleanup_command) + + if '' in result: + # Package build failed + return False, None + if '[100%] Project compiled successfully.' in result: + # Package build success + return True, parsed_toml + + +def validate(): + packages = db.packages.find({"versions": {"$elemMatch": {"isVerified": False}}}) + packages = list(packages) + for package in packages: + for i in package['versions']: + if 'isVerified' in i.keys() and i['isVerified'] == False: + tarball = file_storage.get(ObjectId(i['oid'])) + packagename = package['name'] + '-' + i['version'] + with open(f"static/temp/{packagename}.tar.gz", "wb") as f: + f.write(tarball.read()) + result = process_package(packagename) + if result[0] == False: + db.packages.update_one({"name": packages['name'],"namespace":package['namespace']}, {"$set": {"versions.$[elem].unabletoVerify": True}}, array_filters=[{"elem.version": i['version']}]) + print("Package build failed for " + packagename) + else: + print("Package build success for " + packagename) + db.packages.update_one({"name": package['name'],"namespace":package['namespace']}, {"$set": {"versions.$[elem].isVerified": True}}, array_filters=[{"elem.version": i['version']}]) + + update_data = {} + + for key in ['repository', 'copyright', 'description']: + if key in result[1] and package[key] == "Package Under Verification": + update_data[key] = result[1][key] + + if update_data: + db.packages.update_one({"name": package['name'],"namespace":package['namespace']}, {"$set": update_data}) + return 0 + +validate() diff --git a/backend/validate_package.py b/backend/validate_package.py deleted file mode 100755 index 103727df..00000000 --- a/backend/validate_package.py +++ /dev/null @@ -1,28 +0,0 @@ -import os -import docker -from app import app -import toml - -# Package testing container -client = docker.from_env() -container = client.containers.run( - "validate_package", - tty=True, - detach=True, - network_disabled=False -) - - -def validate(package, packagename): - data = package.read() - container.put_archive(os.path.dirname(f"/home/registry/{packagename}.tar.gz"), data) - build_response = container.exec_run(f'sh -c "cd /home/registry/{packagename} && /home/registry/fpm build"') - toml_data = container.exec_run(f'sh -c "cd /home/registry/{packagename} && cat fpm.toml"') - parsed_toml = toml.loads(str(toml_data, 'utf-8')) - container.exec_run(f'sh -c "cd /home/registry/ && rm -rf {packagename}"') - if b'' in build_response.output: - # Package build failed - return False, None - if b'[100%] Project compiled successfully.' in build_response.output: - # Package build success - return True, parsed_toml From b72e5f617ac9eab8bfbf7bc397f1c7e9f359a0ec Mon Sep 17 00:00:00 2001 From: arteevraina Date: Sun, 4 Feb 2024 23:33:46 +0530 Subject: [PATCH 2/5] fix: from_json method for Packages and Version --- backend/models/package.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/backend/models/package.py b/backend/models/package.py index d37fb377..28090b7a 100644 --- a/backend/models/package.py +++ b/backend/models/package.py @@ -1,6 +1,7 @@ class Package: def __init__(self, name, namespace, description, homepage, repository, - copyright, license, created_at, updated_at, author, maintainers, tags, is_deprecated, versions=[], id=None): + copyright, license, created_at, updated_at, author, maintainers, tags, is_deprecated, versions=[], id=None, + malicious_report={}, is_verified=False, is_malicious=False, security_status="No security issues found", ratings={"users": {}, "avg_ratings": 0}): self.id = id self.name = name self.namespace = namespace @@ -16,15 +17,12 @@ def __init__(self, name, namespace, description, homepage, repository, self.tags = tags self.is_deprecated = is_deprecated self.versions = versions - self.malicious_report = {} - self.is_verified = False - self.is_malicious = False - self.security_status = "No security issues found" + self.malicious_report = malicious_report + self.is_verified = is_verified + self.is_malicious = is_malicious + self.security_status = security_status self.downloads_stats = {} - self.ratings = { - "users": {}, - "avg_ratings": 0, - } + self.ratings = ratings # Ensure that versions list only contains instances of Version class for v in self.versions: @@ -96,14 +94,14 @@ def from_json(json_data): ) class Version: - def __init__(self, version, tarball, dependencies, created_at, is_deprecated, download_url): + def __init__(self, version, tarball, dependencies, created_at, is_deprecated, download_url, is_verified=False): self.version = version self.tarball = tarball self.dependencies = dependencies self.created_at = created_at self.is_deprecated = is_deprecated self.download_url = download_url - self.is_verified = False + self.is_verified = is_verified # Create a to_json method. def to_json(self): From f5ae65bf40975045184bb0cfda5902c4b8836d82 Mon Sep 17 00:00:00 2001 From: arteevraina Date: Sun, 4 Feb 2024 23:34:27 +0530 Subject: [PATCH 3/5] fix: division by zero error in ratings calculation --- backend/packages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/packages.py b/backend/packages.py index 960165b0..0e0f908a 100644 --- a/backend/packages.py +++ b/backend/packages.py @@ -514,7 +514,7 @@ def get_package(namespace_name, package_name): "version_history": package_obj.to_json()["versions"], "updated_at": package_obj.updated_at, "description": package_obj.description, - "ratings": round(sum(package_obj.ratings['users'].values())/len(package_obj.ratings['users']),3), + "ratings": round(sum(package_obj.ratings['users'].values())/len(package_obj.ratings['users']),3) if len(package_obj.ratings['users']) > 0 else 0, "downloads": package_obj.downloads_stats, } From 4485488e31e980bb1bb7b357a9baf520378234c5 Mon Sep 17 00:00:00 2001 From: Arteev Raina Date: Sat, 10 Feb 2024 15:00:26 +0530 Subject: [PATCH 4/5] Report package UI (#66) * feat: added the UI for report form * feat: connected ui to the report package api * fix: show correct responses from the server * refactor: post malicous api and remove stale conditions --- backend/packages.py | 20 ---- frontend/src/pages/package.js | 20 +++- frontend/src/pages/reportPackageForm.js | 99 +++++++++++++++++++ .../src/store/actions/reportPackageActions.js | 52 ++++++++++ .../store/reducers/reportPackageReducer.js | 48 +++++++++ frontend/src/store/reducers/rootReducer.js | 2 + 6 files changed, 219 insertions(+), 22 deletions(-) create mode 100644 frontend/src/pages/reportPackageForm.js create mode 100644 frontend/src/store/actions/reportPackageActions.js create mode 100644 frontend/src/store/reducers/reportPackageReducer.js diff --git a/backend/packages.py b/backend/packages.py index 0e0f908a..1d5fecf4 100644 --- a/backend/packages.py +++ b/backend/packages.py @@ -957,26 +957,6 @@ def post_malicious(namespace, package): } return jsonify({"message": error_message}), 404 - if user["_id"] in package_doc["malicious_report"]["users"] and package_doc["malicious_report"][ - "users" - ][user["_id"]]['reason'] == str(reason): - return jsonify({"message": "Malicious Report Submitted Successfully", "code": 200}), 200 - - if user["_id"] in package_doc["malicious_report"]["users"] and package_doc["malicious_report"][ - "users" - ][user["_id"]]['reason'] != str(reason): - package_version_doc = db.packages.update_one( - {"name": package, "namespace": namespace_doc["_id"]}, - { - "$set": { - f"malicious_report.users.{user['_id']}": { 'reason': str(reason), 'isViewed': False }, - "malicious_report.isViewed": False, - - }, - }, - ) - return jsonify({"message": "Malicious Report Updated Successfully", "code": 200}), 200 - package_version_doc = db.packages.update_one( {"name": package, "namespace": namespace_doc["_id"]}, { diff --git a/frontend/src/pages/package.js b/frontend/src/pages/package.js index 3362fe5e..4b300c31 100644 --- a/frontend/src/pages/package.js +++ b/frontend/src/pages/package.js @@ -22,6 +22,8 @@ import { verifyUserRole, } from "../store/actions/packageActions"; import ShowUserListDialog from "./showUserListDialog"; +import ReportPackageForm from "./reportPackageForm"; +import { Button } from "react-bootstrap"; const PackagePage = () => { const [iconsActive, setIconsActive] = useState("readme"); @@ -33,6 +35,7 @@ const PackagePage = () => { const navigate = useNavigate(); const [togglePackageMaintainersDialog, settogglePackageMaintainersDialog] = useState(false); + const [showReportForm, setShowReportForm] = useState(false); const handleIconsClick = (value) => { if (value === iconsActive) { @@ -111,7 +114,7 @@ const PackagePage = () => { {data.description} - {sideBar(data)} + {sideBar(data, setShowReportForm)} @@ -177,6 +180,12 @@ const PackagePage = () => { + setShowReportForm(false)} + /> ) : ( @@ -226,7 +235,7 @@ const ViewPackageMaintainersButton = ({ ); }; -const sideBar = (data) => { +const sideBar = (data, setShowReportForm) => { return (

Install

@@ -248,6 +257,13 @@ const sideBar = (data) => {

Last publish

{updatedDays(data.updated_at)} days ago
+
); }; diff --git a/frontend/src/pages/reportPackageForm.js b/frontend/src/pages/reportPackageForm.js new file mode 100644 index 00000000..fcfc1cec --- /dev/null +++ b/frontend/src/pages/reportPackageForm.js @@ -0,0 +1,99 @@ +import React, { useEffect, useState } from "react"; +import { Form, Button, Modal, Spinner } from "react-bootstrap"; +import { useDispatch, useSelector } from "react-redux"; +import { + reportPackage, + resetErrorMessage, +} from "../store/actions/reportPackageActions"; +import { toast, ToastContainer } from "react-toastify"; +import { reset } from "../store/actions/accountActions"; + +const ReportPackageForm = (props) => { + const dispatch = useDispatch(); + const [reason, setReason] = useState(""); + const accessToken = useSelector((state) => state.auth.accessToken); + const isLoading = useSelector((state) => state.reportPackage.isLoading); + const statusCode = useSelector((state) => state.reportPackage.statuscode); + const message = useSelector((state) => state.reportPackage.message); + + const handleSubmit = async (e) => { + e.preventDefault(); + dispatch( + reportPackage( + { reason: reason, namespace: props.namespace, package: props.package }, + accessToken + ) + ); + }; + + useEffect(() => { + console.log("Inside use effect"); + if (statusCode === 200) { + toast.success(message); + } else { + toast.error(message); + } + + dispatch(resetErrorMessage()); + }, [statusCode]); + + return ( +
+ + + Report Package + + + + + Reason for reporting package + setReason(e.target.value)} + /> + + Write a brief description of the reason for reporting the package. + + + + + {!isLoading ? ( + + ) : ( +
+ + Loading... + +
+ )} +
+
+
+ ); +}; + +export default ReportPackageForm; diff --git a/frontend/src/store/actions/reportPackageActions.js b/frontend/src/store/actions/reportPackageActions.js new file mode 100644 index 00000000..1d730e63 --- /dev/null +++ b/frontend/src/store/actions/reportPackageActions.js @@ -0,0 +1,52 @@ +import axios from "axios"; + +export const REPORT_PACKAGE_REQUEST = "REPORT_PACKAGE_REQUEST"; +export const REPORT_PACKAGE_SUCCESS = "REPORT_PACKAGE_SUCCESS"; +export const REPORT_PACKAGE_FAILURE = "REPORT_PACKAGE_FAILURE"; + +export const RESET_ERROR_MESSAGE = "RESET_ERROR_MESSAGE"; + +export const reportPackage = (data, access_token) => async (dispatch) => { + let formData = new FormData(); + formData.append("reason", data.reason); + + let package_name = data.package; + let namespace_name = data.namespace; + + try { + dispatch({ + type: REPORT_PACKAGE_REQUEST, + }); + + const result = await axios({ + method: "post", + url: `${process.env.REACT_APP_REGISTRY_API_URL}/report/${namespace_name}/${package_name}`, + data: formData, + headers: { + Authorization: `Bearer ${access_token}`, + }, + }); + + dispatch({ + type: REPORT_PACKAGE_SUCCESS, + payload: { + message: result.data.message, + statuscode: result.data.code, + }, + }); + } catch (error) { + dispatch({ + type: REPORT_PACKAGE_FAILURE, + payload: { + message: error.response.data.message, + statuscode: error.response.data.code, + }, + }); + } +}; + +export const resetErrorMessage = () => (dispatch) => { + dispatch({ + type: RESET_ERROR_MESSAGE, + }); +}; diff --git a/frontend/src/store/reducers/reportPackageReducer.js b/frontend/src/store/reducers/reportPackageReducer.js new file mode 100644 index 00000000..dd135ba4 --- /dev/null +++ b/frontend/src/store/reducers/reportPackageReducer.js @@ -0,0 +1,48 @@ +import { + REPORT_PACKAGE_REQUEST, + REPORT_PACKAGE_SUCCESS, + REPORT_PACKAGE_FAILURE, + RESET_ERROR_MESSAGE, +} from "../actions/reportPackageActions"; + +const initialState = { + isLoading: false, + error: null, + message: null, + statuscode: 0, +}; + +const reportPackageReducer = (state = initialState, action) => { + switch (action.type) { + case REPORT_PACKAGE_REQUEST: + return { + ...state, + isLoading: true, + }; + case REPORT_PACKAGE_SUCCESS: + return { + ...state, + isLoading: false, + message: action.payload.message, + statuscode: action.payload.statuscode, + }; + case REPORT_PACKAGE_FAILURE: + return { + ...state, + isLoading: false, + message: action.payload.message, + statuscode: action.payload.statuscode, + }; + case RESET_ERROR_MESSAGE: + return { + ...state, + error: null, + message: null, + statuscode: 0, + }; + default: + return state; + } +}; + +export default reportPackageReducer; diff --git a/frontend/src/store/reducers/rootReducer.js b/frontend/src/store/reducers/rootReducer.js index 4d784525..4865194b 100644 --- a/frontend/src/store/reducers/rootReducer.js +++ b/frontend/src/store/reducers/rootReducer.js @@ -17,6 +17,7 @@ import addRemoveNamespaceMaintainerReducer from "./namespaceMaintainersReducer"; import addRemoveNamespaceAdminReducer from "./namespaceAdminReducer"; import verifyEmailReducer from "./verifyEmailReducer"; import userListReducer from "./userListReducer"; +import reportPackageReducer from "./reportPackageReducer"; const rootReducer = combineReducers({ auth: authReducer, @@ -37,6 +38,7 @@ const rootReducer = combineReducers({ verifyEmail: verifyEmailReducer, userList: userListReducer, archives: archivesReducer, + reportPackage: reportPackageReducer, }); export default rootReducer; From ee50da607f3b2e7f6c51d83adeb894603c8a862b Mon Sep 17 00:00:00 2001 From: Arteev Raina Date: Sat, 17 Feb 2024 22:36:06 +0530 Subject: [PATCH 5/5] feat: ratings API fixes and UI in place. (#67) * refactor: post rating api * dep: added recharts to the dependencies * fix: added the UI for the charts * fix: fetch the count details for graph from backend * fix: exception when count key is not present * fix: exception due to updated_at typo * fix: fallback condition for ui if no ratings available --- backend/packages.py | 57 +++++++++++++++--------- backend/user.py | 2 +- frontend/package.json | 1 + frontend/src/pages/package.js | 14 ++++++ frontend/src/pages/packageRatingGraph.js | 26 +++++++++++ frontend/src/pages/reportPackageForm.js | 1 - 6 files changed, 78 insertions(+), 23 deletions(-) create mode 100644 frontend/src/pages/packageRatingGraph.js diff --git a/backend/packages.py b/backend/packages.py index 1d5fecf4..f289761c 100644 --- a/backend/packages.py +++ b/backend/packages.py @@ -516,6 +516,7 @@ def get_package(namespace_name, package_name): "description": package_obj.description, "ratings": round(sum(package_obj.ratings['users'].values())/len(package_obj.ratings['users']),3) if len(package_obj.ratings['users']) > 0 else 0, "downloads": package_obj.downloads_stats, + "ratings_count": package_obj.ratings["counts"] if "counts" in package_obj.ratings else {}, } return jsonify({"data": package_response_data, "code": 200}) @@ -892,35 +893,49 @@ def post_ratings(namespace, package): } return jsonify({"message": error_message}), 404 - if user["_id"] in package_doc["ratings"]["users"] and package_doc["ratings"][ - "users" - ][user["_id"]] == int(rating): - return jsonify({"message": "Ratings Submitted Successfully", "code": 200}), 200 - - if user["_id"] in package_doc["ratings"]["users"] and package_doc["ratings"][ - "users" - ][user["_id"]] != int(rating): - package_version_doc = db.packages.update_one( - {"name": package, "namespace": namespace_doc["_id"]}, - { - "$set": { - f"ratings.users.{user['_id']}": int(rating), - }, - }, - ) - return jsonify({"message": "Ratings Updated Successfully", "code": 200}), 200 - - package_version_doc = db.packages.update_one( + db.packages.update_one( {"name": package, "namespace": namespace_doc["_id"]}, { "$set": { f"ratings.users.{user['_id']}": int(rating), }, - "$inc": { - "ratings.total_count": 1, + }, + ) + + # Iterate through ratings and calculate how many users rated 5, 4, 3, 2, 1. + ratings = db.packages.find_one( + {"name": package, "namespace": namespace_doc["_id"]} + )["ratings"]["users"] + + ratings_count = { + "5": 0, + "4": 0, + "3": 0, + "2": 0, + "1": 0, + } + + for user_id, user_rating in ratings.items(): + if user_rating == 5: + ratings_count["5"] += 1 + elif user_rating == 4: + ratings_count["4"] += 1 + elif user_rating == 3: + ratings_count["3"] += 1 + elif user_rating == 2: + ratings_count["2"] += 1 + elif user_rating == 1: + ratings_count["1"] += 1 + + db.packages.update_one( + {"name": package, "namespace": namespace_doc["_id"]}, + { + "$set": { + "ratings.counts": ratings_count, }, }, ) + return jsonify({"message": "Ratings Submitted Successfully", "code": 200}), 200 diff --git a/backend/user.py b/backend/user.py index 07c51ef6..2acff09f 100644 --- a/backend/user.py +++ b/backend/user.py @@ -118,7 +118,7 @@ def profile(username): "name": package["name"], "namespace": namespace["namespace"], "description": package["description"], - "updatedAt": package["updatedAt"], + "updatedAt": package["updated_at"], "isNamespaceMaintainer": isNamespaceMaintainer, "isPackageMaintainer": isPackageMaintainer, } diff --git a/frontend/package.json b/frontend/package.json index abd851fd..a1b351f7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "react-router-dom": "^6.11.2", "react-scripts": "5.0.1", "react-toastify": "^9.1.3", + "recharts": "^2.12.0", "redux": "^4.2.1", "redux-persist": "^6.0.0", "styled-components": "^5.3.10", diff --git a/frontend/src/pages/package.js b/frontend/src/pages/package.js index 4b300c31..fe5fb3ad 100644 --- a/frontend/src/pages/package.js +++ b/frontend/src/pages/package.js @@ -24,6 +24,7 @@ import { import ShowUserListDialog from "./showUserListDialog"; import ReportPackageForm from "./reportPackageForm"; import { Button } from "react-bootstrap"; +import PackageRatingGraph from "./packageRatingGraph"; const PackagePage = () => { const [iconsActive, setIconsActive] = useState("readme"); @@ -106,6 +107,14 @@ const PackagePage = () => { Versions + + handleIconsClick("stats")} + active={iconsActive === "stats"} + > + Stats + + @@ -179,6 +188,11 @@ const PackagePage = () => { + + + + + { + const graphData = data; + + const parsedArray = Object.entries(graphData).map(([key, value]) => ({ + name: `${key} ⭐`, + star: value, + })); + + return parsedArray.length === 0 ? ( +
+ No stats available right now. This will update as soon as the package gets + rated. +
+ ) : ( + + + + + + ); +}; + +export default PackageRatingGraph; diff --git a/frontend/src/pages/reportPackageForm.js b/frontend/src/pages/reportPackageForm.js index fcfc1cec..73d78b3d 100644 --- a/frontend/src/pages/reportPackageForm.js +++ b/frontend/src/pages/reportPackageForm.js @@ -27,7 +27,6 @@ const ReportPackageForm = (props) => { }; useEffect(() => { - console.log("Inside use effect"); if (statusCode === 200) { toast.success(message); } else {