diff --git a/.env b/.env new file mode 100644 index 0000000..eeb4b5d --- /dev/null +++ b/.env @@ -0,0 +1,68 @@ +# WARN: +# For production, you must update those +GIT_USER_PASSWORD=git +SPRING_DRUID_PASSWORD=druid +GIT_SERVER_DOMAIN=localhost +SPRING_MAIL_HOST= +SPRING_MAIL_PORT= +SPRING_MAIL_USERNAME= +SPRING_MAIL_PASSWORD= +SPRING_MAIL_PROTOCOL= +MD5_SALT="Is that the best you can do?" +GCS_SSH_MAPPING_PORT=10623 +FRONT_END_REVERSE_PROXY_PORT=80 + + +# NOTE: +# This is for development, you could update them +GCS_SPRING_MAPPING_PORT=8080 + +# NOTE: +# Make sure GCS_SSH_MAPPING_PORT and GCS_SPRING_MAPPING_PORT could be +# accessed by others +POSTGRES_MAPPING_PATH=/var/gcs/postgres +TARGET_JAR_PATH=./target/gcs-0.1.0-SNAPSHOT.jar + +# NOTE: +# In most situations, you do not need to update those +GIT_USER_NAME=git +GIT_USER_MAIN_GROUP=git +GIT_USER_HOME=/home/git +GITOLITE_REPOSITORY=${GIT_USER_HOME}/gitolite +GITOLITE_INSTALLATION_DIR=${GIT_USER_HOME}/bin +GITOLITE_ADMIN_REPOSITORY=/root/gitolite-admin +GITOLITE_ADMIN_REPOSITORY_USER_NAME=root +GITOLITE_ADMIN_REPOSITORY_USER_EMAIL=root@localhost +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=postgres +JAVA_WORKING_DIRECTORY=/gcs + +SPRING_PROFILES_ACTIVE=prod +SPRING_DATASOURCE_URL=jdbc:postgresql://gcs-postgres:5432/${POSTGRES_DB} +SPRING_DATASOURCE_USERNAME=${POSTGRES_USER} +SPRING_DATASOURCE_PASSWORD=${POSTGRES_PASSWORD} +SPRING_DRUID_USERNAME=druid +SPRING_REDIS_HOST=gcs-redis +SPRING_REDIS_PORT=6379 +SPRING_MAIL_DEFAULT_ENCODING=UTF-8 +GIT_SERVER_PORT=${GCS_SSH_MAPPING_PORT} +GIT_SERVER_USERNAME=${GIT_USER_NAME} +GIT_SERVER_HOME=${GIT_USER_HOME} +GIT_SERVER_ADMIN_REPOSITORY=${GITOLITE_ADMIN_REPOSITORY} +FRONT_END_URL= + +GCS_LOGGING_DIRECTORY=/var/gcs/logs +# every log file's max size is 10MB +GCS_LOGGING_FILE_MAX_SIZE=10MB +# keep 30 days logs +GCS_LOGGING_MAX_HISTORY=30 +# total size of logs is 1GB +GCS_LOGGING_TOTAL_SIZE_CAP=1GB + +# NOTE: +# Those below are to avoid hard-code, +# You need not modify them +GITOLITE_USER_REPOSITORIES=${GIT_USER_HOME}/repositories +DATABASE_INIT_SCRIPT_PATH=./database/init +GITOLITE_PATH=./3rdparty/gitolite diff --git a/.github/workflows/java-format.yml b/.github/workflows/java-format.yml new file mode 100644 index 0000000..e0572ac --- /dev/null +++ b/.github/workflows/java-format.yml @@ -0,0 +1,45 @@ +name: Google Java Style Format + +on: + pull_request: + branches: [ master, develop ] + +jobs: + java-formatting: + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + with: + token: ${{ secrets.PAT }} + + - name: Check for Java files + id: check_java_files + run: | + if [ -n "$(find . -name '*.java' -print -quit)" ]; then + echo "java_files_exist=true" >> $GITHUB_OUTPUT + else + echo "java_files_exist=false" >> $GITHUB_OUTPUT + fi + + - name: Set up openjdk-17 + uses: actions/setup-java@v4 + if: steps.check_java_files.outputs.java_files_exist == 'true' + with: + distribution: 'zulu' + java-version: '17' + + - name: Google Java Style Format + if: steps.check_java_files.outputs.java_files_exist == 'true' + uses: axel-op/googlejavaformat-action@v3 + with: + # --aosp: 4-space indentation + args: "--replace --aosp" + # Can not auto commit, we'll commit manually + skip-commit: true + + - name: Commit Changes + if: steps.check_java_files.outputs.java_files_exist == 'true' + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "Apply Google Java Style Format" diff --git a/.gitignore b/.gitignore index 524f096..a99912a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,40 @@ +# maven target +target + +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + # Compiled class file *.class @@ -22,3 +59,6 @@ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* replay_pid* + +# root directory for the file explorer +.root/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a537b5b --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "3rdparty/gitolite"] + path = 3rdparty/gitolite + url = https://github.com/sitaramc/gitolite diff --git a/3rdparty/gitolite b/3rdparty/gitolite new file mode 160000 index 0000000..a546e5e --- /dev/null +++ b/3rdparty/gitolite @@ -0,0 +1 @@ +Subproject commit a546e5e8bdbb7069b995ca95fd20556157b0b439 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2476118 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,70 @@ +FROM ubuntu:24.04 + +RUN apt-get update && apt-get install -y sudo openssh-server git openjdk-17-jre-headless nodejs + +ARG GIT_USER_NAME=git +ARG GIT_USER_MAIN_GROUP="$GIT_USER_NAME" +ARG GIT_USER_PASSWORD="$GIT_USER_NAME" +ARG GIT_USER_HOME="/home/$GIT_USER_NAME" +ARG GITOLITE_REPOSITORY="$GIT_USER_HOME/gitolite" +ARG GITOLITE_INSTALLATION_DIR="$GIT_USER_HOME/bin" +ARG GITOLITE_ADMIN_REPOSITORY=/root/gitolite-admin +ARG GITOLITE_ADMIN_REPOSITORY_USER_NAME=root +ARG GITOLITE_ADMIN_REPOSITORY_USER_EMAIL=root@localhost +ARG GITOLITE_PATH=./3rdparty/gitolite +ARG JAVA_WORKING_DIRECTORY=/gcs +ARG TARGET_JAR_PATH=./target/gcs.jar + +RUN useradd -m "$GIT_USER_NAME" && echo "$GIT_USER_NAME:$GIT_USER_PASSWORD" | chpasswd + +COPY "$GITOLITE_PATH" "$GITOLITE_REPOSITORY" + +RUN chown -R "$GIT_USER_NAME:$GIT_USER_MAIN_GROUP" "$GITOLITE_REPOSITORY" && \ + sudo -u "$GIT_USER_NAME" mkdir -p "$GITOLITE_INSTALLATION_DIR" && \ + sudo -u "$GIT_USER_NAME" "$GITOLITE_REPOSITORY/install" -to "$GITOLITE_INSTALLATION_DIR" && \ + ssh-keygen -t rsa -b 4096 -f /root/.ssh/id_rsa -N "" && \ + cp /root/.ssh/id_rsa.pub "$GIT_USER_HOME/root.pub" && \ + chown "$GIT_USER_NAME:$GIT_USER_MAIN_GROUP" "$GIT_USER_HOME/root.pub" && \ + sudo -u "$GIT_USER_NAME" "$GITOLITE_INSTALLATION_DIR/gitolite" setup -pk "$GIT_USER_HOME/root.pub" + +RUN service ssh restart && \ + ssh-keyscan -p 22 localhost >> /root/.ssh/known_hosts && \ + git clone "$GIT_USER_NAME@localhost:gitolite-admin" "$GITOLITE_ADMIN_REPOSITORY" && \ + mkdir -p "$GITOLITE_ADMIN_REPOSITORY/conf/gitolite.d/user" && \ + mkdir -p "$GITOLITE_ADMIN_REPOSITORY/conf/gitolite.d/repository" && \ + echo "\ +@admin = root\n\ +repo gitolite-admin\n\ + RW+ = @admin\n\ +repo testing\n\ + RW+ = @admin\n\ +include \"gitolite.d/user/*.conf\"\n\ +include \"gitolite.d/repository/*.conf\"\n\ +@all_public_repo = testing\n\ +repo @all_public_repo\n\ + R = @all" > "$GITOLITE_ADMIN_REPOSITORY/conf/gitolite.conf" && \ + git -C "$GITOLITE_ADMIN_REPOSITORY" config user.name "$GITOLITE_ADMIN_REPOSITORY_USER_NAME" && \ + git -C "$GITOLITE_ADMIN_REPOSITORY" config user.email "$GITOLITE_ADMIN_REPOSITORY_USER_EMAIL" && \ + git -C "$GITOLITE_ADMIN_REPOSITORY" commit -am "Init the gitolite-admin" && \ + git -C "$GITOLITE_ADMIN_REPOSITORY" push + +RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime + +EXPOSE 22 8080 + +WORKDIR "$JAVA_WORKING_DIRECTORY" + +COPY "$TARGET_JAR_PATH" "gcs.jar" + +RUN echo "\ + service ssh restart && \ + git -C $GITOLITE_ADMIN_REPOSITORY fetch && \ + git -C $GITOLITE_ADMIN_REPOSITORY reset --hard origin/master && \ + cp ~/.ssh/id_rsa.pub $GITOLITE_ADMIN_REPOSITORY/keydir/root.pub && \ + (git -C $GITOLITE_ADMIN_REPOSITORY commit -am 'Update root.pub' && git push -f || true) && \ + java -jar gcs.jar" \ + > \ + "start.sh" + +ENTRYPOINT ["bash", "start.sh"] + diff --git a/README-zh.md b/README-zh.md new file mode 100644 index 0000000..e549ec9 --- /dev/null +++ b/README-zh.md @@ -0,0 +1,68 @@ +# gcs-back-end + +`git` 中央仓库服务的后端实现。 + +# 部署介绍 + +## 使用 `docker-compose` 进行部署 + +现在提供了 `docker-compose` 部署方式。 + +第一步,使用以下命令获取仓库: + +```bash +git clone --recursive https://github.com/CMIPT/gcs-back-end.git +``` + +或者: + +```bash +git clone https://github.com/CMIPT/gcs-back-end.git +git submodule init +git submodule update +``` + +确保安装 `mvn` 和 `jdk17`,使用 `mvn` 打包: + +```bash +mvn package +``` + +配置 `.env` 中的环境变量,对于生产环境,你需要配置: + +```bash +GIT_USER_PASSWORD= +POSTGRES_PASSWORD= +SPRING_DRUID_PASSWORD= +GIT_SERVER_DOMAIN= +SPRING_MAIL_HOST= +SPRING_MAIL_PORT= +SPRING_MAIL_USERNAME= +SPRING_MAIL_PASSWORD= +SPRING_MAIL_PROTOCOL= +MD5_SALT= +GCS_SSH_MAPPING_PORT= +GCS_SPRING_MAPPING_PORT= +TARGET_JAR_PATH= +``` + +对于开发环境,你只需要配置: + +```bash +SPRING_MAIL_HOST= +SPRING_MAIL_PORT= +SPRING_MAIL_USERNAME= +SPRING_MAIL_PASSWORD= +SPRING_MAIL_PROTOCOL= +TARGET_JAR_PATH= +``` + +配置完成后,使用 `docker-compose build` 构建镜像,使用 `docker-compose up -d` 启动服务。 + +在开发过程中,可以通过以下命令对包进行替换: + +```bash +mvn package && \ +docker cp :/gcs/gcs.jar && \ +docker restart +``` diff --git a/README.md b/README.md index a232a5c..30b5fa8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # gcs-back-end + The back-end for git center server. diff --git a/clean_ubuntu.sh b/clean_ubuntu.sh new file mode 100644 index 0000000..caeb553 --- /dev/null +++ b/clean_ubuntu.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# USAGE: bash clean_ubuntu.sh [config_file] + +config_file=${1:-"config.json"} + +# TODO: reuse the log_error and log_info functions from deploy_ubuntu.sh +log_error () { + echo -e "\e[31m[ERROR]: $1\e[0m" + exit 1 +} + +log_info () { + echo "[INFO]: $1" +} + +log_info "Cleaning up..." +python script/deploy_helper.py \ + --config-path "$config_file" \ + --clean \ + --distro ubuntu \ + --default-config-path ./config_default.json || \ + log_error "Failed to run deploy_helper.py for cleaning up the environment" diff --git a/database/diagram/gcs_back_end.drawio b/database/diagram/gcs_back_end.drawio new file mode 100644 index 0000000..904b02c --- /dev/null +++ b/database/diagram/gcs_back_end.drawio @@ -0,0 +1,480 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/database/diagram/gcs_back_end.png b/database/diagram/gcs_back_end.png new file mode 100644 index 0000000..5ff1b1e Binary files /dev/null and b/database/diagram/gcs_back_end.png differ diff --git a/database/init/001_t_repository.sql b/database/init/001_t_repository.sql new file mode 100644 index 0000000..5f46b23 --- /dev/null +++ b/database/init/001_t_repository.sql @@ -0,0 +1,32 @@ +CREATE TABLE public.t_repository ( + id bigint NOT NULL, + repository_name character varying(255) NOT NULL, + repository_description character varying(255) NOT NULL, + is_private boolean DEFAULT false, + user_id bigint NOT NULL, + star integer DEFAULT 0 NOT NULL, + fork integer DEFAULT 0 NOT NULL, + watcher integer DEFAULT 0 NOT NULL, + https_url character varying(1024) NOT NULL, + ssh_url character varying(1024) NOT NULL, + gmt_created timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + gmt_updated timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + gmt_deleted timestamp without time zone +); + +COMMENT ON TABLE public.t_repository IS 'Table for storing repository information.'; + +COMMENT ON COLUMN public.t_repository.id IS 'Primary key of the repository table.'; +COMMENT ON COLUMN public.t_repository.repository_name IS 'Name of the repository.'; +COMMENT ON COLUMN public.t_repository.repository_description IS 'Description of the repository.'; +COMMENT ON COLUMN public.t_repository.is_private IS 'Indicates if the repository is private.'; +COMMENT ON COLUMN public.t_repository.user_id IS 'ID of the user who owns the repository.'; +COMMENT ON COLUMN public.t_repository.star IS 'Number of stars the repository has received.'; +COMMENT ON COLUMN public.t_repository.fork IS 'Number of times the repository has been forked.'; +COMMENT ON COLUMN public.t_repository.watcher IS 'Number of users watching the repository.'; +COMMENT ON COLUMN public.t_repository.https_url IS 'Repository link under HTTPS protocol.'; +COMMENT ON COLUMN public.t_repository.ssh_url IS 'Repository link under SSH protocol.'; +COMMENT ON COLUMN public.t_repository.gmt_created IS 'Timestamp when the repository was created.'; +COMMENT ON COLUMN public.t_repository.gmt_updated IS 'Timestamp when the repository was last updated.'; +COMMENT ON COLUMN public.t_repository.gmt_deleted IS 'Timestamp when the repository was deleted. +If set to NULL, it indicates that the repository has not been deleted.'; diff --git a/database/init/001_t_ssh_key.sql b/database/init/001_t_ssh_key.sql new file mode 100644 index 0000000..0af685f --- /dev/null +++ b/database/init/001_t_ssh_key.sql @@ -0,0 +1,19 @@ +CREATE TABLE public.t_ssh_key ( + id bigint NOT NULL, + user_id bigint NOT NULL, + name character varying(255) NOT NULL, + public_key character varying(4096) NOT NULL, + gmt_created timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + gmt_updated timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + gmt_deleted timestamp without time zone +); + +COMMENT ON TABLE public.t_ssh_key IS 'Table for storing ssh public key.'; +COMMENT ON COLUMN public.t_ssh_key.id IS 'Primary key of the ssh_key table.'; +COMMENT ON COLUMN public.t_ssh_key.user_id IS 'ID of the user who owns the ssh key.'; +COMMENT ON COLUMN public.t_ssh_key.name IS 'Name of the ssh key.'; +COMMENT ON COLUMN public.t_ssh_key.public_key IS 'Public key of the ssh key.'; +COMMENT ON COLUMN public.t_ssh_key.gmt_created IS 'Timestamp when the ssh_key record was created.'; +COMMENT ON COLUMN public.t_ssh_key.gmt_updated IS 'Timestamp when the ssh_key record was last updated.'; +COMMENT ON COLUMN public.t_ssh_key.gmt_deleted IS 'Timestamp when the ssh_key record was deleted. +If set to NULL, it indicates that the ssh_key record has not been deleted.'; diff --git a/database/init/001_t_user.sql b/database/init/001_t_user.sql new file mode 100644 index 0000000..8213bd1 --- /dev/null +++ b/database/init/001_t_user.sql @@ -0,0 +1,21 @@ +CREATE TABLE public.t_user ( + id bigint NOT NULL, + username character varying(50) NOT NULL, + email character varying(254) NOT NULL, + user_password character(32) NOT NULL, + avatar_url character varying(1024) NOT NULL default '', + gmt_created timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + gmt_updated timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + gmt_deleted timestamp without time zone +); + +COMMENT ON TABLE public.t_user IS 'Table for storing user information.'; + +COMMENT ON COLUMN public.t_user.id IS 'Primary key of the user table.'; +COMMENT ON COLUMN public.t_user.username IS 'Username of the user.'; +COMMENT ON COLUMN public.t_user.email IS 'Email address of the user.'; +COMMENT ON COLUMN public.t_user.user_password IS 'Password of the user, stored as an MD5 hash.'; +COMMENT ON COLUMN public.t_user.gmt_created IS 'Timestamp when the user record was created.'; +COMMENT ON COLUMN public.t_user.gmt_updated IS 'Timestamp when the user record was last updated.'; +COMMENT ON COLUMN public.t_user.gmt_deleted IS 'Timestamp when the user record was deleted. +If set to NULL, it indicates that the user information has not been deleted.'; diff --git a/database/init/001_t_user_collaborate_repository.sql b/database/init/001_t_user_collaborate_repository.sql new file mode 100644 index 0000000..6ab8e73 --- /dev/null +++ b/database/init/001_t_user_collaborate_repository.sql @@ -0,0 +1,18 @@ +CREATE TABLE public.t_user_collaborate_repository ( + id bigint NOT NULL, + collaborator_id bigint NOT NULL, + repository_id bigint NOT NULL, + gmt_created timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + gmt_updated timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + gmt_deleted timestamp without time zone +); + +COMMENT ON TABLE public.t_user_collaborate_repository IS 'Table for collaboration relationship.'; + +COMMENT ON COLUMN public.t_user_collaborate_repository.id IS 'Primary key of the collaboration relationship table.'; +COMMENT ON COLUMN public.t_user_collaborate_repository.collaborator_id IS 'ID of the collaborator.'; +COMMENT ON COLUMN public.t_user_collaborate_repository.repository_id IS 'ID of the repository.'; +COMMENT ON COLUMN public.t_user_collaborate_repository.gmt_created IS 'Timestamp when the relationship was created.'; +COMMENT ON COLUMN public.t_user_collaborate_repository.gmt_updated IS 'Timestamp when the relationship was last updated.'; +COMMENT ON COLUMN public.t_user_collaborate_repository.gmt_deleted IS 'Timestamp when the relationship was deleted. +If set to NULL, it indicates that the repository has not been deleted.'; diff --git a/database/init/001_t_user_star_repository.sql b/database/init/001_t_user_star_repository.sql new file mode 100644 index 0000000..f9033f7 --- /dev/null +++ b/database/init/001_t_user_star_repository.sql @@ -0,0 +1,18 @@ +CREATE TABLE public.t_user_star_repository ( + id bigint NOT NULL, + user_id bigint NOT NULL, + repository_id bigint NOT NULL, + gmt_created timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + gmt_updated timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + gmt_deleted timestamp without time zone +); + +COMMENT ON TABLE public.t_user_star_repository IS 'Table for storing relationships between users and starred repositories.'; + +COMMENT ON COLUMN public.t_user_star_repository.id IS 'Primary key of the user_star_repository table.'; +COMMENT ON COLUMN public.t_user_star_repository.user_id IS 'ID of the user who starred the repository.'; +COMMENT ON COLUMN public.t_user_star_repository.repository_id IS 'ID of the repository that has been starred.'; +COMMENT ON COLUMN public.t_user_star_repository.gmt_created IS 'Timestamp when the relationship was created.'; +COMMENT ON COLUMN public.t_user_star_repository.gmt_updated IS 'Timestamp when the relationship was last updated.'; +COMMENT ON COLUMN public.t_user_star_repository.gmt_deleted IS 'Timestamp when the relationship was deleted. +If set to NULL, it indicates that this relationship has not been deleted.'; diff --git a/database/init/002_all_column_constraint.sql b/database/init/002_all_column_constraint.sql new file mode 100644 index 0000000..ebb4da8 --- /dev/null +++ b/database/init/002_all_column_constraint.sql @@ -0,0 +1,34 @@ +-- The constarint of the primary key and unique key is added to the table. +ALTER TABLE ONLY public.t_repository + ADD CONSTRAINT pk_repository PRIMARY KEY (id); +CREATE UNIQUE INDEX unique_t_repository_name_user_id ON public.t_repository + (LOWER(repository_name), user_id, gmt_deleted); + +-- The constraint of t_user is added to the table. +ALTER TABLE ONLY public.t_user ADD CONSTRAINT pk_user_table PRIMARY KEY (id); +CREATE UNIQUE INDEX unique_t_user_username ON public.t_user (LOWER(username), gmt_deleted); +CREATE UNIQUE INDEX unique_t_user_email ON public.t_user (LOWER(email), gmt_deleted); + +-- The constraint of t_user_star_repository is added to the table. +ALTER TABLE ONLY public.t_user_star_repository + ADD CONSTRAINT pk_user_star_repository PRIMARY KEY (id); +ALTER TABLE ONLY public.t_user_star_repository + ADD CONSTRAINT unique_t_user_star_repository_user_id_repository_id + UNIQUE (user_id, repository_id, gmt_deleted); + +-- The constraint of t_ssh_key is added to the table. +ALTER TABLE ONLY public.t_ssh_key + ADD CONSTRAINT pk_ssh_key PRIMARY KEY (id); +ALTER TABLE ONLY public.t_ssh_key + ADD CONSTRAINT unique_t_ssh_key_user_id_public_key + UNIQUE (user_id, public_key, gmt_deleted); +ALTER TABLE ONLY public.t_ssh_key + ADD CONSTRAINT unique_t_ssh_key_user_id_name_key + UNIQUE (user_id, name, gmt_deleted); + +-- The constraint of t_user_collaborate_repository is added to the table. +ALTER TABLE ONLY public.t_user_collaborate_repository + ADD CONSTRAINT pk_user_collaborate_repository PRIMARY KEY (id); +ALTER TABLE ONLY public.t_user_collaborate_repository + ADD CONSTRAINT t_user_collaborate_repository_collaborator_id_repository_id + UNIQUE (collaborator_id, repository_id, gmt_deleted); diff --git a/deploy_ubuntu.sh b/deploy_ubuntu.sh new file mode 100644 index 0000000..25e1338 --- /dev/null +++ b/deploy_ubuntu.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +config_file=${1:-"config.json"} + +log_error () { + echo -e "\e[31m[ERROR]: $1\e[0m" + exit 1 +} + +log_info () { + echo "[INFO]: $1" +} + +log_info "Config file: ${config_file}" + +apt_updated=false +install_package() { + local sudo_cmd + sudo_cmd=$(command -v sudo) + if [ "$apt_updated" = false ]; then + ${sudo_cmd} apt-get update + apt_updated=true + fi + ${sudo_cmd} apt-get install -y "$1" || log_error "Failed to install $1" +} + +# install essential packages +install_package python-is-python3 + +log_info "Deploying..." +python script/deploy_helper.py \ + --config-path "$config_file" \ + --distro ubuntu \ + --default-config-path ./config_default.json || \ + log_error "Failed to run deploy_helper.py for deploying the environment" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c65edc7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,71 @@ +services: + gcs-postgres: + image: postgres:14 + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - gcs-postgres-data:/var/lib/postgresql/data + - ${DATABASE_INIT_SCRIPT_PATH}:/docker-entrypoint-initdb.d + gcs-redis: + image: redis:6 + restart: always + gcs: + restart: always + build: + context: . + dockerfile: Dockerfile + args: + GIT_USER_NAME: ${GIT_USER_NAME} + GIT_USER_MAIN_GROUP: ${GIT_USER_MAIN_GROUP} + GIT_USER_PASSWORD: ${GIT_USER_PASSWORD} + GIT_USER_HOME: ${GIT_USER_HOME} + GITOLITE_REPOSITORY: ${GITOLITE_REPOSITORY} + GITOLITE_INSTALLATION_DIR: ${GITOLITE_INSTALLATION_DIR} + GITOLITE_ADMIN_REPOSITORY: ${GITOLITE_ADMIN_REPOSITORY} + GITOLITE_ADMIN_REPOSITORY_USER_NAME: ${GITOLITE_ADMIN_REPOSITORY_USER_NAME} + GITOLITE_ADMIN_REPOSITORY_USER_EMAIL: ${GITOLITE_ADMIN_REPOSITORY_USER_EMAIL} + JAVA_WORKING_DIRECTORY: ${JAVA_WORKING_DIRECTORY} + GITOLITE_PATH: ${GITOLITE_PATH} + TARGET_JAR_PATH: ${TARGET_JAR_PATH} + volumes: + - gcs-user-repositories:${GITOLITE_USER_REPOSITORIES} + ports: + - ${GCS_SPRING_MAPPING_PORT}:8080 + - ${GCS_SSH_MAPPING_PORT}:22 + - ${FRONT_END_REVERSE_PROXY_PORT}:3000 + + depends_on: + - gcs-postgres + - gcs-redis + environment: + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE} + SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL} + SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} + SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} + SPRING_DRUID_USERNAME: ${SPRING_DRUID_USERNAME} + SPRING_DRUID_PASSWORD: ${SPRING_DRUID_PASSWORD} + SPRING_REDIS_HOST: ${SPRING_REDIS_HOST} + SPRING_REDIS_PORT: ${SPRING_REDIS_PORT} + SPRING_MAIL_HOST: ${SPRING_MAIL_HOST} + SPRING_MAIL_PORT: ${SPRING_MAIL_PORT} + SPRING_MAIL_USERNAME: ${SPRING_MAIL_USERNAME} + SPRING_MAIL_PASSWORD: ${SPRING_MAIL_PASSWORD} + SPRING_MAIL_PROTOCOL: ${SPRING_MAIL_PROTOCOL} + SPRING_MAIL_DEFAULT_ENCODING: ${SPRING_MAIL_DEFAULT_ENCODING} + GIT_SERVER_DOMAIN: ${GIT_SERVER_DOMAIN} + GIT_SERVER_PORT: ${GIT_SERVER_PORT} + GIT_SERVER_USERNAME: ${GIT_SERVER_USERNAME} + GIT_SERVER_HOME: ${GIT_SERVER_HOME} + GIT_SERVER_ADMIN_REPOSITORY: ${GIT_SERVER_ADMIN_REPOSITORY} + FRONT_END_URL: ${FRONT_END_URL} + MD5_SALT: ${MD5_SALT} + GCS_LOGGING_DIRECTORY: ${GCS_LOGGING_DIRECTORY} + GCS_LOGGING_FILE_MAX_SIZE: ${GCS_LOGGING_FILE_MAX_SIZE} + GCS_LOGGING_MAX_HISTORY: ${GCS_LOGGING_MAX_HISTORY} + GCS_LOGGING_TOTAL_SIZE_CAP: ${GCS_LOGGING_TOTAL_SIZE_CAP} +volumes: + gcs-postgres-data: + gcs-user-repositories: diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..3a86795 --- /dev/null +++ b/pom.xml @@ -0,0 +1,164 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.3.2 + + + edu.cmipt + gcs + 0.1.0-SNAPSHOT + gcs + The back end of git center server + + + + MIT License + https://opensource.org/licenses/MIT + repo + + + + + Kaiser + kaiserqzyue@gmail.com + https://github.com/Kaiser-Yang + + Project Manager + Architect + Developer + + CMIPT + https://github.com/CMIPT + 8 + + + ajiankexx + ajianke2@gmail.com + https://github.com/ajiankexx + + Developer + + CMIPT + https://github.com/CMIPT + 8 + + + Quentin + quentincheung261@gmail.com + https://github.com/Quentin9922 + + Developer + + CMIPT + https://github.com/CMIPT + 8 + + + + + + + + + + 17 + + jar + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.6.0 + + + com.alibaba + druid-spring-boot-3-starter + 1.2.23 + + + org.postgresql + postgresql + runtime + + + org.springframework.boot + spring-boot-starter-jdbc + + + com.baomidou + mybatis-plus-spring-boot3-starter + 3.5.7 + + + org.springframework.boot + spring-boot-starter-validation + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-mail + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/prepare_dev.sh b/prepare_dev.sh new file mode 100644 index 0000000..ad3121c --- /dev/null +++ b/prepare_dev.sh @@ -0,0 +1,75 @@ +#!/usr/bin/bash + +# Usage: bash prepare_dev.sh + +# install necessary packages +sudo apt-get update +sudo apt-get install -y postgresql postgresql-client openjdk-17-jdk-headless maven git openssh-server +if ! command -v redis-cli; then + sudo apt-get install lsb-release curl gpg + curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg + sudo chmod 644 /usr/share/keyrings/redis-archive-keyring.gpg + echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list + sudo apt-get update + sudo apt-get install redis +fi + +# init the database +sudo su -c 'psql -c "DROP DATABASE IF EXISTS gcs_dev;"' postgres +sudo su -c 'psql -c "CREATE DATABASE gcs_dev;"' postgres +bash database/database_deploy.sh postgres gcs_dev localhost 5432 "$1" + +# configure gitolite +sudo userdel -r git +sudo useradd -m git +sudo su -c 'git clone https://github.com/sitaramc/gitolite /home/git/gitolite' git +sudo su -c 'mkdir -p /home/git/bin; /home/git/gitolite/install -to /home/git/bin' git +sudo cp /home/"$USER"/.ssh/id_rsa.pub /home/git/"$USER".pub +sudo chown git:git /home/git/"$USER".pub +sudo su -c "/home/git/bin/gitolite setup -pk /home/git/$USER.pub" git +rm -rf /home/"$USER"/gitolite-admin +GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git clone \ + ssh://git@localhost:22/gitolite-admin /home/"$USER"/gitolite-admin +mkdir -p /home/"$USER"/gitolite-admin/conf/gitolite.d/user +mkdir -p /home/"$USER"/gitolite-admin/conf/gitolite.d/repository +echo " +repo gitolite-admin + RW+ = $USER +repo testing + R = @all +include \"gitolite.d/user/*.conf\" +include \"gitolite.d/repository/*.conf\" +@all_public_repo = +repo @all_public_repo + R = @all" > /home/"$USER"/gitolite-admin/conf/gitolite.conf +git -C /home/"$USER"/gitolite-admin add conf/gitolite.conf +git -C /home/"$USER"/gitolite-admin commit -m "Init the gitolite-admin" +git -C /home/"$USER"/gitolite-admin push + +echo "$USER ALL=(git) NOPASSWD: /usr/bin/rm" | sudo tee /etc/sudoers.d/gcs_dev + +echo " +spring.datasource.druid.username=postgres +spring.datasource.druid.password=$1 +spring.datasource.druid.url=jdbc:postgresql://localhost:5432/gcs_dev +spring.datasource.druid.stat-view-servlet.login-username=druid +spring.datasource.druid.stat-view-servlet.login-password=druid +spring.profiles.active=dev +git.server.domain=localhost +git.server.port=22 +git.user.name=git +git.home.directory=/home/git +md5.salt=Is that the best you can do? +front-end.url= +spring.mvc.static-path-pattern= +spring.resources.static-locations= +gitolite.admin.repository.path=/home/$USER/gitolite-admin +spring.redis.host=localhost +spring.redis.port=6379 +spring.mail.default-encoding=UTF-8 +spring.mail.protocol= +spring.mail.host= +spring.mail.port= +spring.mail.username= +spring.mail.password= +" > src/main/resources/application.properties diff --git a/src/main/java/edu/cmipt/gcs/GcsApplication.java b/src/main/java/edu/cmipt/gcs/GcsApplication.java new file mode 100644 index 0000000..b3a5769 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/GcsApplication.java @@ -0,0 +1,14 @@ +package edu.cmipt.gcs; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +@SpringBootApplication +@EnableTransactionManagement +public class GcsApplication { + + public static void main(String[] args) { + SpringApplication.run(GcsApplication.class, args); + } +} diff --git a/src/main/java/edu/cmipt/gcs/config/DruidConfig.java b/src/main/java/edu/cmipt/gcs/config/DruidConfig.java new file mode 100644 index 0000000..2fbde4e --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/config/DruidConfig.java @@ -0,0 +1,6 @@ +package edu.cmipt.gcs.config; + +import org.springframework.context.annotation.Configuration; + +@Configuration +public class DruidConfig {} diff --git a/src/main/java/edu/cmipt/gcs/config/MybatisPlusConfig.java b/src/main/java/edu/cmipt/gcs/config/MybatisPlusConfig.java new file mode 100644 index 0000000..1c3f1be --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/config/MybatisPlusConfig.java @@ -0,0 +1,21 @@ +package edu.cmipt.gcs.config; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("edu.cmipt.gcs.dao") +public class MybatisPlusConfig { + + @Bean + MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE_SQL)); + return interceptor; + } +} diff --git a/src/main/java/edu/cmipt/gcs/config/RedisConfig.java b/src/main/java/edu/cmipt/gcs/config/RedisConfig.java new file mode 100644 index 0000000..5d71d41 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/config/RedisConfig.java @@ -0,0 +1,37 @@ +package edu.cmipt.gcs.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + @Value("${spring.redis.host}") + private String redisHost; + + @Value("${spring.redis.port}") + private Integer redisPort; + + @Bean + LettuceConnectionFactory luaConnectionFactory() { + var redisConfig = new RedisStandaloneConfiguration(); + redisConfig.setHostName(redisHost); + redisConfig.setPort(redisPort); + return new LettuceConnectionFactory(redisConfig); + } + + @Bean + RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + return redisTemplate; + } +} diff --git a/src/main/java/edu/cmipt/gcs/config/WebConfig.java b/src/main/java/edu/cmipt/gcs/config/WebConfig.java new file mode 100644 index 0000000..2b9abb8 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/config/WebConfig.java @@ -0,0 +1,74 @@ +package edu.cmipt.gcs.config; + +import edu.cmipt.gcs.constant.ApiPathConstant; +import edu.cmipt.gcs.constant.ApplicationConstant; +import edu.cmipt.gcs.constant.HeaderParameter; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.Ordered; +import org.springframework.http.HttpMethod; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +import java.util.Arrays; + +@Configuration +public class WebConfig { + @Value("${front-end.url}") + private String frontEndUrl; + + @Bean + @Profile(ApplicationConstant.DEV_PROFILE) + FilterRegistrationBean corsFilterDev() { + CorsConfiguration config = new CorsConfiguration(); + config.addAllowedOrigin("*"); + config.addAllowedMethod("*"); + config.addAllowedHeader("*"); + addExposedHeader(config); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + FilterRegistrationBean bean = + new FilterRegistrationBean<>(new CorsFilter(source)); + bean.setOrder(Ordered.LOWEST_PRECEDENCE); + return bean; + } + + @Bean + @Profile({ApplicationConstant.PROD_PROFILE, ApplicationConstant.TEST_PROFILE}) + FilterRegistrationBean corsFilterNonDev() { + CorsConfiguration config = new CorsConfiguration(); + if (frontEndUrl != null && frontEndUrl.length() > 0) { + config.addAllowedOrigin(frontEndUrl); + config.addAllowedMethod(HttpMethod.GET); + config.addAllowedMethod(HttpMethod.POST); + config.addAllowedMethod(HttpMethod.DELETE); + config.addAllowedHeader("*"); + addExposedHeader(config); + } + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration(ApiPathConstant.ALL_API_PREFIX + "/**", config); + FilterRegistrationBean bean = + new FilterRegistrationBean<>(new CorsFilter(source)); + bean.setOrder(Ordered.LOWEST_PRECEDENCE); + return bean; + } + + private void addExposedHeader(CorsConfiguration config) { + Arrays.stream(HeaderParameter.class.getFields()) + .forEach( + field -> { + try { + if (field.getType() == String.class && field.canAccess(null)) { + config.addExposedHeader((String) field.get(null)); + } + } catch (IllegalAccessException e) { + // ignore + } + }); + } +} diff --git a/src/main/java/edu/cmipt/gcs/constant/ApiPathConstant.java b/src/main/java/edu/cmipt/gcs/constant/ApiPathConstant.java new file mode 100644 index 0000000..c135da6 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/constant/ApiPathConstant.java @@ -0,0 +1,67 @@ +package edu.cmipt.gcs.constant; + +public class ApiPathConstant { + public static final String ALL_API_PREFIX = "/api"; + + public static final String AUTHENTICATION_API_PREFIX = ALL_API_PREFIX + "/auth"; + public static final String AUTHENTICATION_SIGN_IN_API_PATH = + AUTHENTICATION_API_PREFIX + "/signin"; + public static final String AUTHENTICATION_SIGN_OUT_API_PATH = + AUTHENTICATION_API_PREFIX + "/signout"; + public static final String AUTHENTICATION_REFRESH_API_PATH = + AUTHENTICATION_API_PREFIX + "/refresh"; + public static final String AUTHENTICATION_SEND_EMAIL_VERIFICATION_CODE_API_PATH = + AUTHENTICATION_API_PREFIX + "/send-email-verification-code"; + + public static final String DEVELOPMENT_API_PREFIX = ALL_API_PREFIX + "/developer"; + public static final String DEVELOPMENT_GET_API_MAP_API_PATH = DEVELOPMENT_API_PREFIX + "/api"; + public static final String DEVELOPMENT_GET_ERROR_MESSAGE_API_PATH = + DEVELOPMENT_API_PREFIX + "/error"; + public static final String DEVELOPMENT_GET_VO_AS_TS_API_PATH = + DEVELOPMENT_API_PREFIX + "/vo-as-ts"; + + public static final String USER_API_PREFIX = ALL_API_PREFIX + "/user"; + + public static final String USER_CREATE_USER_API_PATH = USER_API_PREFIX + "/create"; + public static final String USER_GET_USER_API_PATH = USER_API_PREFIX + "/get"; + public static final String USER_UPDATE_USER_API_PATH = USER_API_PREFIX + "/update"; + public static final String USER_CHECK_EMAIL_VALIDITY_API_PATH = USER_API_PREFIX + "/email"; + public static final String USER_CHECK_USERNAME_VALIDITY_API_PATH = + USER_API_PREFIX + "/username"; + public static final String USER_CHECK_USER_PASSWORD_VALIDITY_API_PATH = + USER_API_PREFIX + "/user-password"; + public static final String USER_DELETE_USER_API_PATH = USER_API_PREFIX + "/delete"; + public static final String USER_UPDATE_USER_PASSWORD_WITH_OLD_PASSWORD_API_PATH = + USER_API_PREFIX + "/update-password-with-old-password"; + public static final String USER_UPDATE_USER_PASSWORD_WITH_EMAIL_VERIFICATION_CODE_API_PATH = + USER_API_PREFIX + "/update-password-with-email-verification-code"; + + public static final String REPOSITORY_API_PREFIX = ALL_API_PREFIX + "/repository"; + public static final String REPOSITORY_GET_REPOSITORY_API_PATH = REPOSITORY_API_PREFIX + "/get"; + public static final String REPOSITORY_CREATE_REPOSITORY_API_PATH = + REPOSITORY_API_PREFIX + "/create"; + public static final String REPOSITORY_DELETE_REPOSITORY_API_PATH = + REPOSITORY_API_PREFIX + "/delete"; + public static final String REPOSITORY_UPDATE_REPOSITORY_API_PATH = + REPOSITORY_API_PREFIX + "/update"; + public static final String REPOSITORY_PAGE_REPOSITORY_API_PATH = + REPOSITORY_API_PREFIX + "/page"; + public static final String REPOSITORY_CHECK_REPOSITORY_NAME_VALIDITY_API_PATH = + REPOSITORY_API_PREFIX + "/repository-name"; + public static final String REPOSITORY_PAGE_COLLABORATOR_API_PATH = + REPOSITORY_API_PREFIX + "/page/collaborator"; + public static final String REPOSITORY_ADD_COLLABORATOR_API_PATH = + REPOSITORY_API_PREFIX + "/add-collaborator"; + public static final String REPOSITORY_REMOVE_COLLABORATION_API_PATH = + REPOSITORY_API_PREFIX + "/remove-collaborator"; + + public static final String SSH_KEY_API_PREFIX = ALL_API_PREFIX + "/ssh"; + public static final String SSH_KEY_UPLOAD_SSH_KEY_API_PATH = SSH_KEY_API_PREFIX + "/upload"; + public static final String SSH_KEY_UPDATE_SSH_KEY_API_PATH = SSH_KEY_API_PREFIX + "/update"; + public static final String SSH_KEY_DELETE_SSH_KEY_API_PATH = SSH_KEY_API_PREFIX + "/delete"; + public static final String SSH_KEY_PAGE_SSH_KEY_API_PATH = SSH_KEY_API_PREFIX + "/page"; + public static final String SSH_KEY_CHECK_SSH_KEY_NAME_VALIDITY_API_PATH = + SSH_KEY_API_PREFIX + "/ssh-key-name"; + public static final String SSH_KEY_CHECK_SSH_KEY_PUBLIC_KEY_VALIDITY_API_PATH = + SSH_KEY_API_PREFIX + "/ssh-key-publickey"; +} diff --git a/src/main/java/edu/cmipt/gcs/constant/ApplicationConstant.java b/src/main/java/edu/cmipt/gcs/constant/ApplicationConstant.java new file mode 100644 index 0000000..0935492 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/constant/ApplicationConstant.java @@ -0,0 +1,10 @@ +package edu.cmipt.gcs.constant; + +public class ApplicationConstant { + public static final String DEV_PROFILE = "dev"; + public static final String PROD_PROFILE = "prod"; + public static final String TEST_PROFILE = "test"; + public static final long ACCESS_TOKEN_EXPIRATION = 10 * 60 * 1000L; // 10 minutes + public static final long REFRESH_TOKEN_EXPIRATION = 30 * 24 * 60 * 60 * 1000L; // 30 days + public static final long EMAIL_VERIFICATION_CODE_EXPIRATION = 5 * 60 * 1000L; // 5 minutes +} diff --git a/src/main/java/edu/cmipt/gcs/constant/GitConstant.java b/src/main/java/edu/cmipt/gcs/constant/GitConstant.java new file mode 100644 index 0000000..7f77a4a --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/constant/GitConstant.java @@ -0,0 +1,64 @@ +package edu.cmipt.gcs.constant; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.nio.file.Paths; + +@Component +public class GitConstant { + public static String GIT_SERVER_USERNAME; + + public static String GIT_SERVER_HOME; + + public static String GIT_SERVER_DOMAIN; + + public static String GIT_SERVER_PORT; + + public static String GIT_SERVER_ADMIN_REPOSITORY; + + public static String GITOLITE_CONF_DIR_PATH; + + public static String GITOLITE_CONF_FILE_PATH; + + public static String GITOLITE_USER_CONF_DIR_PATH; + + public static String GITOLITE_REPOSITORY_CONF_DIR_PATH; + + public static String GITOLITE_KEY_DIR_PATH; + + @Value("${git.server.username}") + public void setGIT_SERVER_USERNAME(String gitServerUserName) { + GitConstant.GIT_SERVER_USERNAME = gitServerUserName; + } + + @Value("${git.server.home}") + public void setGIT_SERVER_HOME(String gitServerHome) { + GitConstant.GIT_SERVER_HOME = gitServerHome; + } + + @Value("${git.server.domain}") + public void setGIT_SERVER_DOMAIN(String gitServerDomain) { + GitConstant.GIT_SERVER_DOMAIN = gitServerDomain; + } + + @Value("${git.server.port}") + public void setGIT_SERVER_PORT(String gitServerPort) { + GitConstant.GIT_SERVER_PORT = gitServerPort; + } + + @Value("${git.server.admin.repository}") + public void setGIT_SERVER_ADMIN_REPOSITORY(String gitoliteAdminRepositoryPath) { + GitConstant.GIT_SERVER_ADMIN_REPOSITORY = gitoliteAdminRepositoryPath; + GitConstant.GITOLITE_CONF_DIR_PATH = + Paths.get(gitoliteAdminRepositoryPath, "conf").toString(); + GitConstant.GITOLITE_CONF_FILE_PATH = + Paths.get(GITOLITE_CONF_DIR_PATH, "gitolite.conf").toString(); + GitConstant.GITOLITE_USER_CONF_DIR_PATH = + Paths.get(GITOLITE_CONF_DIR_PATH, "gitolite.d", "user").toString(); + GitConstant.GITOLITE_REPOSITORY_CONF_DIR_PATH = + Paths.get(GITOLITE_CONF_DIR_PATH, "gitolite.d", "repository").toString(); + GitConstant.GITOLITE_KEY_DIR_PATH = + Paths.get(gitoliteAdminRepositoryPath, "keydir").toString(); + } +} diff --git a/src/main/java/edu/cmipt/gcs/constant/HeaderParameter.java b/src/main/java/edu/cmipt/gcs/constant/HeaderParameter.java new file mode 100644 index 0000000..030d655 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/constant/HeaderParameter.java @@ -0,0 +1,6 @@ +package edu.cmipt.gcs.constant; + +public class HeaderParameter { + public static final String ACCESS_TOKEN = "Access-Token"; + public static final String REFRESH_TOKEN = "Refresh-Token"; +} diff --git a/src/main/java/edu/cmipt/gcs/constant/ValidationConstant.java b/src/main/java/edu/cmipt/gcs/constant/ValidationConstant.java new file mode 100644 index 0000000..c64ac9a --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/constant/ValidationConstant.java @@ -0,0 +1,30 @@ +package edu.cmipt.gcs.constant; + +public class ValidationConstant { + public static final int MIN_PASSWORD_LENGTH = 6; + public static final int MAX_PASSWORD_LENGTH = 20; + public static final int MIN_USERNAME_LENGTH = 1; + public static final int MAX_USERNAME_LENGTH = 50; + public static final int MIN_AVATAR_URL_LENGTH = 0; + public static final int MAX_AVATAR_URL_LENGTH = 1024; + + // the size of username and password will be check by the @Size, + // so we just use '*' to ignore the length check + public static final String USERNAME_PATTERN = "^[a-zA-Z0-9_-]*$"; + public static final String PASSWORD_PATTERN = "^[a-zA-Z0-9_.@-]*$"; + public static final int MIN_REPOSITORY_NAME_LENGTH = 1; + public static final int MAX_REPOSITORY_NAME_LENGTH = 255; + public static final int MIN_REPOSITORY_DESCRIPTION_LENGTH = 0; + public static final int MAX_REPOSITORY_DESCRIPTION_LENGTH = 255; + // the length will be checked by @Size + public static final String REPOSITORY_NAME_PATTERN = "^[a-zA-Z0-9_-]*$"; + + public static final int MIN_SSH_KEY_NAME_LENGTH = 1; + public static final int MAX_SSH_KEY_NAME_LENGTH = 255; + + public static final int MIN_SSH_KEY_PUBLIC_KEY_LENGTH = 1; + public static final int MAX_SSH_KEY_PUBLIC_KEY_LENGTH = 4096; + + public static final int EMAIL_VERIFICATION_CODE_LENGTH = 6; + public static final String EMAIL_VERIFICATION_CODE_PATTERN = "^[0-9]*$"; +} diff --git a/src/main/java/edu/cmipt/gcs/controller/AuthenticationController.java b/src/main/java/edu/cmipt/gcs/controller/AuthenticationController.java new file mode 100644 index 0000000..9641ae3 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/controller/AuthenticationController.java @@ -0,0 +1,184 @@ +package edu.cmipt.gcs.controller; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; + +import edu.cmipt.gcs.constant.ApiPathConstant; +import edu.cmipt.gcs.constant.ApplicationConstant; +import edu.cmipt.gcs.constant.HeaderParameter; +import edu.cmipt.gcs.enumeration.ErrorCodeEnum; +import edu.cmipt.gcs.exception.GenericException; +import edu.cmipt.gcs.pojo.error.ErrorVO; +import edu.cmipt.gcs.pojo.user.UserPO; +import edu.cmipt.gcs.pojo.user.UserSignInDTO; +import edu.cmipt.gcs.pojo.user.UserVO; +import edu.cmipt.gcs.service.UserService; +import edu.cmipt.gcs.util.EmailVerificationCodeUtil; +import edu.cmipt.gcs.util.JwtUtil; +import edu.cmipt.gcs.util.MD5Converter; +import edu.cmipt.gcs.util.MessageSourceUtil; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +import jakarta.mail.internet.MimeMessage; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * AuthenticationController + * + *

Controller for authentication APIs + * + * @author Kaiser + */ +@RestController +@Tag(name = "Authentication", description = "Authentication APIs") +public class AuthenticationController { + @Value("${spring.mail.username}") + private String fromEmail; + + @Autowired private JavaMailSender javaMailSender; + @Autowired private UserService userService; + + @GetMapping(ApiPathConstant.AUTHENTICATION_SEND_EMAIL_VERIFICATION_CODE_API_PATH) + @Operation( + summary = "Send email verification code", + description = "Send email verification code to the given email", + tags = {"Authentication", "Get Method"}) + @Parameters({ + @Parameter( + name = "email", + description = "Email", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)) + }) + @ApiResponse(responseCode = "200", description = "Email verification code sent successfully") + public void sendEmailVerificationCode( + @RequestParam("email") + @Email( + message = + "{Email.authenticationController#sendEmailVerificationCode.email}") + @NotBlank( + message = + "{NotBlank.authenticationController#sendEmailVerificationCode.email}") + String email) { + String code = EmailVerificationCodeUtil.generateVerificationCode(email); + MimeMessage message = javaMailSender.createMimeMessage(); + try { + MimeMessageHelper helper = new MimeMessageHelper(message, true); + helper.setFrom(fromEmail); + helper.setTo(email); + helper.setSubject(MessageSourceUtil.getMessage("EMAIL_VERIFICATION_CODE_SUBJECT")); + helper.setText( + MessageSourceUtil.getMessage( + "EMAIL_VERIFICATION_CODE_CONTENT", + code, + ApplicationConstant.EMAIL_VERIFICATION_CODE_EXPIRATION / 60000)); + } catch (Exception e) { + throw new GenericException(ErrorCodeEnum.SERVER_ERROR, e); + } + javaMailSender.send(message); + } + + @PostMapping(ApiPathConstant.AUTHENTICATION_SIGN_IN_API_PATH) + @Operation( + summary = "Sign in a user", + description = "Sign in a user with the given information", + tags = {"Authentication", "Post Method"}) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "User signed in successfully", + content = @Content(schema = @Schema(implementation = UserVO.class))), + @ApiResponse( + responseCode = "400", + description = "User sign in failed", + content = @Content(schema = @Schema(implementation = ErrorVO.class))), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + public ResponseEntity signIn(@Validated @RequestBody UserSignInDTO user) { + if (user.username() == null && user.email() == null) { + throw new GenericException(ErrorCodeEnum.MESSAGE_CONVERSION_ERROR); + } + QueryWrapper wrapper = new QueryWrapper(); + if (user.username() != null) { + wrapper.apply("LOWER(username) = LOWER({0})", user.username()); + } else { + wrapper.apply("LOWER(email) = LOWER({0})", user.email()); + } + wrapper.eq("user_password", MD5Converter.convertToMD5(user.userPassword())); + if (!userService.exists(wrapper)) { + throw new GenericException(ErrorCodeEnum.WRONG_SIGN_IN_INFORMATION); + } + UserVO userVO = new UserVO(userService.getOne(wrapper)); + HttpHeaders headers = JwtUtil.generateHeaders(userVO.id()); + return ResponseEntity.ok().headers(headers).body(userVO); + } + + @DeleteMapping(ApiPathConstant.AUTHENTICATION_SIGN_OUT_API_PATH) + @Operation( + summary = "Sign out", + description = "Sign out with the given token", + tags = {"Authentication", "Delete Method"}) + @ApiResponse(responseCode = "200", description = "User signed out successfully") + @Parameters({ + @Parameter( + name = "id", + description = "User ID", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Long.class)) + }) + public void signOut(@RequestParam("id") Long id) { + JwtUtil.blacklistToken(id); + } + + @GetMapping(ApiPathConstant.AUTHENTICATION_REFRESH_API_PATH) + @Operation( + summary = "Refresh token", + description = "Return an access token with given refresh token", + tags = {"Authentication", "Get Method"}) + @Parameters({ + @Parameter( + name = HeaderParameter.REFRESH_TOKEN, + description = "Refresh token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)) + }) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "Token refreshed successfully", + content = @Content(schema = @Schema(implementation = String.class))), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + public ResponseEntity refreshToken( + @RequestHeader(HeaderParameter.REFRESH_TOKEN) String refreshToken) { + HttpHeaders headers = JwtUtil.generateHeaders(JwtUtil.getId(refreshToken), false); + return ResponseEntity.ok().headers(headers).build(); + } +} diff --git a/src/main/java/edu/cmipt/gcs/controller/DevelopmentController.java b/src/main/java/edu/cmipt/gcs/controller/DevelopmentController.java new file mode 100644 index 0000000..274ad5d --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/controller/DevelopmentController.java @@ -0,0 +1,223 @@ +package edu.cmipt.gcs.controller; + +import edu.cmipt.gcs.constant.ApiPathConstant; +import edu.cmipt.gcs.constant.ApplicationConstant; +import edu.cmipt.gcs.enumeration.ErrorCodeEnum; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import jakarta.annotation.PostConstruct; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * DevelopmentController + * + *

Controller for development APIs + * + * @author Kaiser + */ +@RestController +@Profile(ApplicationConstant.DEV_PROFILE) +@Tag(name = "Development", description = "Some Useful APIs for Development") +public class DevelopmentController { + private static final Logger logger = LoggerFactory.getLogger(DevelopmentController.class); + + private Map errorCodeConstant = new HashMap<>(); + + private Map apiPathConstant = new HashMap<>(); + + private Map voAsTS = new HashMap<>(); + + @PostConstruct + public void init() { + for (ErrorCodeEnum code : ErrorCodeEnum.values()) { + if (code == ErrorCodeEnum.ZERO_PLACEHOLDER) { + continue; + } + errorCodeConstant.put(code.name(), code.ordinal()); + } + for (Field field : ApiPathConstant.class.getFields()) { + try { + if (field.getName().endsWith("API_PATH")) { + apiPathConstant.put(field.getName(), (String) field.get(null)); + } + } catch (Exception e) { + // impossible + throw new RuntimeException(e); + } + } + List> voClassList = findVOClasses(); + for (Class voClass : voClassList) { + StringBuilder tsDefinitions = new StringBuilder(); + tsDefinitions.append("export type ").append(voClass.getSimpleName()); + TypeVariable[] typeParameters = voClass.getTypeParameters(); + if (typeParameters.length > 0) { + tsDefinitions.append("<"); + for (int i = 0; i < typeParameters.length; i++) { + tsDefinitions.append(typeParameters[i].getName()); + if (i < typeParameters.length - 1) { + tsDefinitions.append(", "); + } + } + tsDefinitions.append(">"); + } + tsDefinitions.append(" = {\n"); + for (Field field : voClass.getDeclaredFields()) { + if (!Modifier.isStatic(field.getModifiers())) { + tsDefinitions + .append(" ") + .append(field.getName()) + .append(": ") + .append(mapJavaTypeToTypeScript(field)) + .append(";\n"); + } + } + tsDefinitions.append("}"); + voAsTS.put(voClass.getSimpleName(), tsDefinitions.toString()); + } + } + + @GetMapping(ApiPathConstant.DEVELOPMENT_GET_API_MAP_API_PATH) + @Operation( + summary = "Get all API paths", + description = "Get all API paths in the application", + tags = {"Development", "Get Method"}) + @ApiResponse(responseCode = "200", description = "API paths retrieved successfully") + public Map getApiMap() { + return apiPathConstant; + } + + @GetMapping(ApiPathConstant.DEVELOPMENT_GET_ERROR_MESSAGE_API_PATH) + @Operation( + summary = "Get all error messages", + description = "Get all error messages in the application", + tags = {"Development", "Get Method"}) + @ApiResponse(responseCode = "200", description = "Error messages retrieved successfully") + public Map getErrorMessage() { + return errorCodeConstant; + } + + @GetMapping(ApiPathConstant.DEVELOPMENT_GET_VO_AS_TS_API_PATH) + @Operation( + summary = "Get VO as TypeScript", + description = "Get VO as TypeScript in the application", + tags = {"Development", "Get Method"}) + @Parameter( + name = "voName", + description = "Value Object Name, when not provided, all VOs will be returned", + required = false, + example = "PageVO", + schema = @Schema(implementation = String.class)) + @ApiResponse(responseCode = "200", description = "VO as TypeScript retrieved successfully") + public String getVOAsTS(@RequestParam(required = false) String voName) { + if (voName == null) { + return voAsTS.values().stream().reduce("", (a, b) -> a + "\n" + b); + } else { + if (voAsTS.containsKey(voName)) { + return voAsTS.get(voName); + } else { + return "No such VO found"; + } + } + } + + private List> findVOClasses() { + List> classes = new ArrayList<>(); + try { + Files.walk( + Paths.get( + getClass() + .getClassLoader() + .getResource("edu/cmipt/gcs/pojo") + .toURI())) + .filter(Files::isRegularFile) + .forEach( + file -> { + String fileName = file.getFileName().toString(); + if (fileName.endsWith("VO.class")) { + String className = + file.toString() + .replace( + getClass() + .getClassLoader() + .getResource("") + .getPath(), + "") + .replace("/", ".") + .replace(".class", ""); + if (className.startsWith(".")) { + className = className.substring(1); + } + logger.debug("Find VO class: {}", className); + try { + classes.add(Class.forName(className)); + } catch (ClassNotFoundException e) { + logger.debug("Class not found: {}", className); + } + } + }); + } catch (Exception e) { + logger.debug("Error while finding VO classes: {}", e.getMessage()); + } + if (classes.isEmpty()) { + logger.debug("No VO class found"); + } + return classes; + } + + private String mapJavaTypeToTypeScript(Field field) { + Type genericType = field.getGenericType(); + if (genericType instanceof ParameterizedType) { + ParameterizedType parameterizedType = (ParameterizedType) genericType; + Class rawType = (Class) parameterizedType.getRawType(); + Type[] actualTypeArguments = parameterizedType.getActualTypeArguments(); + if (rawType.equals(List.class) && actualTypeArguments.length == 1) { + return "Array<" + mapJavaTypeToTypeScript(actualTypeArguments[0]) + ">"; + } + } + return mapJavaTypeToTypeScript(field.getType()); + } + + private String mapJavaTypeToTypeScript(Type javaType) { + if (javaType.equals(String.class)) { + return "string"; + } else if (javaType.equals(int.class) + || javaType.equals(Integer.class) + || javaType.equals(long.class) + || javaType.equals(Long.class) + || javaType.equals(double.class) + || javaType.equals(Double.class) + || javaType.equals(float.class) + || javaType.equals(Float.class)) { + return "number"; + } else if (javaType.equals(boolean.class) || javaType.equals(Boolean.class)) { + return "boolean"; + } else if (javaType instanceof TypeVariable) { + return ((TypeVariable) javaType).getName(); + } else { + return "any"; // Default to 'any' for complex types + } + } +} diff --git a/src/main/java/edu/cmipt/gcs/controller/RepositoryController.java b/src/main/java/edu/cmipt/gcs/controller/RepositoryController.java new file mode 100644 index 0000000..3ad82ad --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/controller/RepositoryController.java @@ -0,0 +1,642 @@ +package edu.cmipt.gcs.controller; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; + +import edu.cmipt.gcs.constant.ApiPathConstant; +import edu.cmipt.gcs.constant.HeaderParameter; +import edu.cmipt.gcs.constant.ValidationConstant; +import edu.cmipt.gcs.enumeration.ErrorCodeEnum; +import edu.cmipt.gcs.exception.GenericException; +import edu.cmipt.gcs.pojo.collaboration.UserCollaborateRepositoryPO; +import edu.cmipt.gcs.pojo.other.PageVO; +import edu.cmipt.gcs.pojo.repository.RepositoryDTO; +import edu.cmipt.gcs.pojo.repository.RepositoryPO; +import edu.cmipt.gcs.pojo.repository.RepositoryVO; +import edu.cmipt.gcs.pojo.user.UserPO; +import edu.cmipt.gcs.pojo.user.UserVO; +import edu.cmipt.gcs.service.RepositoryService; +import edu.cmipt.gcs.service.UserCollaborateRepositoryService; +import edu.cmipt.gcs.service.UserService; +import edu.cmipt.gcs.util.JwtUtil; +import edu.cmipt.gcs.validation.group.CreateGroup; +import edu.cmipt.gcs.validation.group.UpdateGroup; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@Tag(name = "Repository", description = "Repository Related APIs") +public class RepositoryController { + private static final Logger logger = LoggerFactory.getLogger(SshKeyController.class); + @Autowired private RepositoryService repositoryService; + @Autowired private UserService userService; + @Autowired private UserCollaborateRepositoryService userCollaborateRepositoryService; + + @PostMapping(ApiPathConstant.REPOSITORY_CREATE_REPOSITORY_API_PATH) + @Operation( + summary = "Create a repository", + description = "Create a repository with the given information", + tags = {"Repository", "Post Method"}) + @Parameters({ + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)) + }) + @ApiResponse(responseCode = "200", description = "Repository created successfully") + public void createRepository( + @Validated(CreateGroup.class) @RequestBody RepositoryDTO repository, + @RequestHeader(HeaderParameter.ACCESS_TOKEN) String accessToken) { + Long userId = Long.valueOf(JwtUtil.getId(accessToken)); + checkRepositoryNameValidity(repository.repositoryName(), userId); + String username = userService.getById(userId).getUsername(); + RepositoryPO repositoryPO = new RepositoryPO(repository, userId.toString(), username, true); + if (!repositoryService.save(repositoryPO)) { + throw new GenericException(ErrorCodeEnum.REPOSITORY_CREATE_FAILED, repository); + } + } + + @DeleteMapping(ApiPathConstant.REPOSITORY_DELETE_REPOSITORY_API_PATH) + @Operation( + summary = "Delete a repository", + description = "Delete a repository with the given id", + tags = {"Repository", "Delete Method"}) + @Parameters({ + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "id", + description = "Repository id", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Long.class)) + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Repository deleted successfully"), + @ApiResponse(responseCode = "403", description = "Access denied"), + @ApiResponse(responseCode = "404", description = "Repository not found") + }) + public void deleteRepository( + @RequestHeader(HeaderParameter.ACCESS_TOKEN) String accessToken, + @RequestParam("id") Long id) { + var repository = repositoryService.getById(id); + if (repository == null) { + throw new GenericException(ErrorCodeEnum.REPOSITORY_NOT_FOUND, id); + } + String userId = JwtUtil.getId(accessToken); + if (!userId.equals(repository.getUserId().toString())) { + logger.info( + "User[{}] tried to delete repository of user[{}]", + userId, + repository.getUserId()); + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); + } + if (!repositoryService.removeById(id)) { + throw new GenericException(ErrorCodeEnum.REPOSITORY_DELETE_FAILED, id); + } + } + + @GetMapping(ApiPathConstant.REPOSITORY_GET_REPOSITORY_API_PATH) + @Operation( + summary = "Get a repository", + description = "Get a repository with the given id or username and repository name", + tags = {"Repository", "Get Method"}) + @Parameters({ + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "id", + description = "Repository Id", + required = false, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "username", + description = "Username", + required = false, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "repositoryName", + description = "Repository Name", + required = false, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)) + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Repository got successfully"), + @ApiResponse(responseCode = "404", description = "Repository not found") + }) + public RepositoryVO getRepository( + @RequestParam(value = "id", required = false) Long id, + @RequestParam(value = "username", required = false) String username, + @RequestParam(value = "repositoryName", required = false) String repositoryName, + @RequestHeader(HeaderParameter.ACCESS_TOKEN) String accessToken) { + RepositoryPO repository; + if (id == null) { + if (username == null || repositoryName == null) { + throw new GenericException(ErrorCodeEnum.MESSAGE_CONVERSION_ERROR); + } + QueryWrapper queryWrapper = new QueryWrapper<>(); + QueryWrapper userQueryWrapper = new QueryWrapper<>(); + userQueryWrapper.apply("LOWER(username) = LOWER({0})", username); + var user = userService.getOne(userQueryWrapper); + if (user == null) { + throw new GenericException(ErrorCodeEnum.USER_NOT_FOUND, username); + } + queryWrapper.eq("user_id", user.getId()); + queryWrapper.apply("LOWER(repository_name) = LOWER({0})", repositoryName); + repository = repositoryService.getOne(queryWrapper); + } else { + repository = repositoryService.getById(id); + } + String notFoundMessage = id != null ? id.toString() : username + "/" + repositoryName; + if (repository == null) { + throw new GenericException(ErrorCodeEnum.REPOSITORY_NOT_FOUND, notFoundMessage); + } + String idInToken = JwtUtil.getId(accessToken); + if (repository.getIsPrivate() + && !idInToken.equals(repository.getUserId().toString()) + && userCollaborateRepositoryService.getOne( + new QueryWrapper() + .eq("collaborator_id", idInToken) + .eq("repository_id", repository.getId())) + == null) { + logger.info( + "User[{}] tried to get repository of user[{}]", + idInToken, + repository.getUserId()); + throw new GenericException(ErrorCodeEnum.REPOSITORY_NOT_FOUND, notFoundMessage); + } + // The server's domain or port may be updated, every query we try to update the url + if (repository.generateUrl(username)) { + repositoryService.updateById(repository); + } + var userPO = userService.getById(repository.getUserId()); + return new RepositoryVO(repository, userPO.getUsername(), userPO.getAvatarUrl()); + } + + @PostMapping(ApiPathConstant.REPOSITORY_UPDATE_REPOSITORY_API_PATH) + @Operation( + summary = "Update a repository", + description = "Update a repository with the given information", + tags = {"Repository", "Post Method"}) + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Repository updated successfully"), + @ApiResponse(responseCode = "403", description = "Access denied"), + @ApiResponse(responseCode = "404", description = "Repository not found"), + @ApiResponse( + responseCode = "501", + description = "Update repository name is not implemented") + }) + public ResponseEntity updateRepository( + @Validated(UpdateGroup.class) @RequestBody RepositoryDTO repository, + @RequestHeader(HeaderParameter.ACCESS_TOKEN) String accessToken) { + Long id = null; + try { + id = Long.valueOf(repository.id()); + } catch (NumberFormatException e) { + logger.error(e.getMessage()); + throw new GenericException(ErrorCodeEnum.MESSAGE_CONVERSION_ERROR); + } + var repositoryPO = repositoryService.getById(id); + if (repositoryPO == null) { + throw new GenericException(ErrorCodeEnum.REPOSITORY_NOT_FOUND, id); + } + Long userId = repositoryPO.getUserId(); + if (!JwtUtil.getId(accessToken).equals(userId.toString())) { + logger.info( + "User[{}] tried to update repository of user[{}]", + userId, + repositoryPO.getUserId()); + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); + } + if (repository.repositoryName() != null) { + throw new GenericException(ErrorCodeEnum.OPERATION_NOT_IMPLEMENTED); + } + if (!repositoryService.updateById(new RepositoryPO(repository))) { + throw new GenericException(ErrorCodeEnum.REPOSITORY_UPDATE_FAILED, repository); + } + var userPO = userService.getById(userId); + return ResponseEntity.ok() + .body( + new RepositoryVO( + repositoryService.getById(id), + userPO.getUsername(), + userPO.getAvatarUrl())); + } + + @GetMapping(ApiPathConstant.REPOSITORY_CHECK_REPOSITORY_NAME_VALIDITY_API_PATH) + @Operation( + summary = "Check repository name validity", + description = "Check if the repository name is valid", + tags = {"Repository", "Get Method"}) + @Parameters({ + @Parameter( + name = "userId", + description = "User id", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Long.class)), + @Parameter( + name = "repositoryName", + description = "Repository name", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)) + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Repository name is valid"), + @ApiResponse(responseCode = "400", description = "Repository name is invalid") + }) + public void checkRepositoryNameValidity( + @RequestParam("repositoryName") + @Size( + min = ValidationConstant.MIN_REPOSITORY_NAME_LENGTH, + max = ValidationConstant.MAX_REPOSITORY_NAME_LENGTH, + message = + "{Size.repositoryController#checkRepositoryNameValidity.repositoryName}") + @NotBlank( + message = + "{NotBlank.repositoryController#checkRepositoryNameValidity.repositoryName}") + @Pattern( + regexp = ValidationConstant.REPOSITORY_NAME_PATTERN, + message = + "{Pattern.repositoryController#checkRepositoryNameValidity.repositoryName}") + String repositoryName, + @RequestParam("userId") Long userId) { + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.eq("user_id", userId); + queryWrapper.apply("LOWER(repository_name) = LOWER({0})", repositoryName); + if (repositoryService.exists(queryWrapper)) { + throw new GenericException(ErrorCodeEnum.REPOSITORY_ALREADY_EXISTS, repositoryName); + } + } + + @PostMapping(ApiPathConstant.REPOSITORY_ADD_COLLABORATOR_API_PATH) + @Operation( + summary = "Add a collaborator", + description = "Add a collaborator to the repository", + tags = {"Repository", "Post Method"}) + @Parameters({ + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "repositoryId", + description = "Repository ID", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Long.class)), + @Parameter( + name = "collaborator", + description = "Collaborator's Information", + example = "admin", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Long.class)), + @Parameter( + name = "collaboratorType", + description = "Collaborator's Type. The value can be 'id', 'username' or 'email'", + example = "username", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)) + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Collaborator added successfully"), + @ApiResponse(responseCode = "403", description = "Access denied"), + @ApiResponse(responseCode = "404", description = "Collaborator or repository not found") + }) + public void addCollaborator( + @RequestParam("repositoryId") Long repositoryId, + @RequestParam("collaborator") String collaborator, + @RequestParam("collaboratorType") String collaboratorType, + @RequestHeader(HeaderParameter.ACCESS_TOKEN) String accessToken) { + if (!collaboratorType.equals("id") + && !collaboratorType.equals("username") + && !collaboratorType.equals("email")) { + throw new GenericException(ErrorCodeEnum.MESSAGE_CONVERSION_ERROR); + } + QueryWrapper userQueryWrapper = new QueryWrapper<>(); + if (collaboratorType.equals("id")) { + try { + userQueryWrapper.eq(collaboratorType, Long.valueOf(collaborator)); + } catch (Exception e) { + throw new GenericException(ErrorCodeEnum.MESSAGE_CONVERSION_ERROR); + } + } else { + userQueryWrapper.eq(collaboratorType, collaborator); + } + UserPO user = userService.getOne(userQueryWrapper); + if (user == null) { + throw new GenericException(ErrorCodeEnum.USER_NOT_FOUND, collaborator); + } + Long collaboratorId = user.getId(); + RepositoryPO repository = repositoryService.getById(repositoryId); + if (repository == null) { + throw new GenericException(ErrorCodeEnum.REPOSITORY_NOT_FOUND, repositoryId); + } + Long idInToken = Long.valueOf(JwtUtil.getId(accessToken)); + Long repositoryUserId = repository.getUserId(); + if (!idInToken.equals(repositoryUserId)) { + logger.error( + "User[{}] tried to add collaborator to repository[{}] whose creator is [{}]", + idInToken, + repositoryId, + repositoryUserId); + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); + } + if (collaboratorId.equals(repositoryUserId)) { + logger.error( + "User[{}] tried to add himself to repository[{}]", + collaboratorId, + repositoryId); + throw new GenericException(ErrorCodeEnum.ILLOGICAL_OPERATION); + } + QueryWrapper collaborationQueryWrapper = new QueryWrapper<>(); + collaborationQueryWrapper.eq("collaborator_id", collaboratorId); + collaborationQueryWrapper.eq("repository_id", repositoryId); + if (userCollaborateRepositoryService.exists(collaborationQueryWrapper)) { + logger.error( + "Collaborator[{}] already exists in repository[{}]", + collaboratorId, + repositoryId); + throw new GenericException( + ErrorCodeEnum.COLLABORATION_ALREADY_EXISTS, collaboratorId, repositoryId); + } + if (!userCollaborateRepositoryService.save( + new UserCollaborateRepositoryPO(collaboratorId, repositoryId))) { + logger.error( + "Failed to add collaborator[{}] to repository[{}]", + collaboratorId, + repositoryId); + throw new GenericException( + ErrorCodeEnum.COLLABORATION_ADD_FAILED, collaboratorId, repositoryId); + } + } + + @DeleteMapping(ApiPathConstant.REPOSITORY_REMOVE_COLLABORATION_API_PATH) + @Operation( + summary = "Remove a collaboration relationship", + description = + "Remove a collaboration relationship between a collaborator and a repository", + tags = {"Repository", "Delete Method"}) + @Parameters({ + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "repositoryId", + description = "Repository's ID", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Long.class)), + @Parameter( + name = "collaboratorId", + description = "Collaborator's ID", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Long.class)) + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Relationship removed successfully"), + @ApiResponse(responseCode = "404", description = "Collaboration not found"), + @ApiResponse(responseCode = "403", description = "Access denied"), + }) + public void removeCollaboration( + @RequestParam("repositoryId") Long repositoryId, + @RequestParam("collaboratorId") Long collaboratorId, + @RequestHeader(HeaderParameter.ACCESS_TOKEN) String accessToken) { + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.eq("collaborator_id", collaboratorId); + queryWrapper.eq("repository_id", repositoryId); + UserCollaborateRepositoryPO userCollaborateRepositoryPO = + userCollaborateRepositoryService.getOne(queryWrapper); + if (userCollaborateRepositoryPO == null) { + throw new GenericException( + ErrorCodeEnum.COLLABORATION_NOT_FOUND, collaboratorId, repositoryId); + } + Long idInToken = Long.valueOf(JwtUtil.getId(accessToken)); + Long repositoryUserId = repositoryService.getById(repositoryId).getUserId(); + if (!idInToken.equals(repositoryUserId)) { + logger.error( + "User[{}] tried to remove collaborator from repository[{}] whose creator is" + + " [{}]", + idInToken, + repositoryId, + repositoryUserId); + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); + } + if (!userCollaborateRepositoryService.removeById(userCollaborateRepositoryPO.getId())) { + logger.error( + "Failed to remove collaborator[{}] from repository[{}]", + collaboratorId, + repositoryId); + throw new GenericException( + ErrorCodeEnum.COLLABORATION_REMOVE_FAILED, collaboratorId, repositoryId); + } + } + + @GetMapping(ApiPathConstant.REPOSITORY_PAGE_COLLABORATOR_API_PATH) + @Operation( + summary = "Page collaborators", + description = "Page collaborators of the repository", + tags = {"Repository", "Get Method"}) + @Parameters({ + @Parameter( + name = "repositoryId", + description = "Repository ID", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Long.class)), + @Parameter( + name = "page", + description = "Page number", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Integer.class)), + @Parameter( + name = "size", + description = "Page size", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Integer.class)), + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)) + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Collaborators paged successfully"), + @ApiResponse(responseCode = "404", description = "Repository not found") + }) + public PageVO pageCollaborator( + @RequestParam("repositoryId") Long repositoryId, + @RequestParam("page") Integer page, + @RequestParam("size") Integer size, + @RequestHeader(HeaderParameter.ACCESS_TOKEN) String accessToken) { + RepositoryPO repository = repositoryService.getById(repositoryId); + if (repository == null) { + throw new GenericException(ErrorCodeEnum.REPOSITORY_NOT_FOUND, repositoryId); + } + Long idInToken = Long.valueOf(JwtUtil.getId(accessToken)); + Long userId = repository.getUserId(); + var iPage = + userCollaborateRepositoryService.pageCollaboratorsByRepositoryId( + repositoryId, new Page<>(page, size)); + // only the creator and collaborators of the repository can page collaborators of a private + // repository + if (repository.getIsPrivate() + && !idInToken.equals(userId) + && iPage.getRecords().stream().noneMatch(user -> user.getId().equals(idInToken))) { + logger.error( + "User[{}] tried to page collaborators of repository[{}] whose creator is [{}]", + idInToken, + repositoryId, + userId); + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); + } + return new PageVO<>( + iPage.getPages(), + iPage.getTotal(), + iPage.getRecords().stream().map(UserVO::new).toList()); + } + + @GetMapping(ApiPathConstant.REPOSITORY_PAGE_REPOSITORY_API_PATH) + @Operation( + summary = "Page user repositories", + description = + "Page user repositories. If the given token is trying to get other's" + + " repositories, only public repositories will be shown", + tags = {"Repository", "Get Method"}) + @Parameters({ + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "id", + description = "User id", + required = false, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Long.class)), + @Parameter( + name = "username", + description = "Username", + required = false, + schema = @Schema(implementation = Long.class)), + @Parameter( + name = "page", + description = "Page number", + example = "1", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Integer.class)), + @Parameter( + name = "size", + description = "Page size", + example = "10", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Integer.class)) + }) + @ApiResponse(responseCode = "200", description = "User repositories paged successfully") + public PageVO pageUserRepository( + @RequestParam(name = "id", required = false) Long userId, + @RequestParam(name = "username", required = false) String username, + @RequestParam("page") Integer page, + @RequestParam("size") Integer size, + @RequestHeader(HeaderParameter.ACCESS_TOKEN) String accessToken) { + if (userId == null && username == null) { + throw new GenericException(ErrorCodeEnum.MESSAGE_CONVERSION_ERROR); + } + UserPO userPO; + if (userId != null) { + userPO = userService.getById(userId); + } else { + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.apply("LOWER(username) = LOWER({0})", username); + userPO = userService.getOne(queryWrapper); + } + if (userPO == null) { + throw new GenericException( + ErrorCodeEnum.USER_NOT_FOUND, userId == null ? username : userId); + } + userId = userPO.getId(); + QueryWrapper wrapper = new QueryWrapper(); + String idInToken = JwtUtil.getId(accessToken); + if (!idInToken.equals(userId.toString())) { + // the user only can see the public repositories of others + wrapper.eq("is_private", false); + } + wrapper.eq("user_id", userId); + var iPage = repositoryService.page(new Page<>(page, size), wrapper); + return new PageVO<>( + iPage.getPages(), + iPage.getTotal(), + iPage.getRecords().stream() + .map( + (RepositoryPO repositoryPO) -> { + if (repositoryPO.generateUrl(userPO.getUsername())) { + repositoryService.updateById(repositoryPO); + } + return new RepositoryVO( + repositoryPO, + userPO.getUsername(), + userPO.getAvatarUrl()); + }) + .toList()); + } +} diff --git a/src/main/java/edu/cmipt/gcs/controller/SshKeyController.java b/src/main/java/edu/cmipt/gcs/controller/SshKeyController.java new file mode 100644 index 0000000..6f1ab4f --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/controller/SshKeyController.java @@ -0,0 +1,334 @@ +package edu.cmipt.gcs.controller; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; + +import edu.cmipt.gcs.constant.ApiPathConstant; +import edu.cmipt.gcs.constant.HeaderParameter; +import edu.cmipt.gcs.constant.ValidationConstant; +import edu.cmipt.gcs.enumeration.ErrorCodeEnum; +import edu.cmipt.gcs.exception.GenericException; +import edu.cmipt.gcs.pojo.error.ErrorVO; +import edu.cmipt.gcs.pojo.other.PageVO; +import edu.cmipt.gcs.pojo.ssh.SshKeyDTO; +import edu.cmipt.gcs.pojo.ssh.SshKeyPO; +import edu.cmipt.gcs.pojo.ssh.SshKeyVO; +import edu.cmipt.gcs.service.SshKeyService; +import edu.cmipt.gcs.util.JwtUtil; +import edu.cmipt.gcs.validation.group.CreateGroup; +import edu.cmipt.gcs.validation.group.UpdateGroup; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.file.Files; +import java.nio.file.Path; + +@Validated +@RestController +@Tag(name = "SSH", description = "SSH APIs") +public class SshKeyController { + private static final Logger logger = LoggerFactory.getLogger(SshKeyController.class); + + @Autowired private SshKeyService sshKeyService; + + @PostMapping(ApiPathConstant.SSH_KEY_UPLOAD_SSH_KEY_API_PATH) + @Operation( + summary = "Upload SSH key", + description = "Upload SSH key with the given information", + tags = {"SSH", "Post Method"}) + @Parameters({ + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)) + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "SSH key uploaded successfully"), + @ApiResponse( + responseCode = "400", + description = "SSH key upload failed", + content = @Content(schema = @Schema(implementation = ErrorVO.class))), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + public void uploadSshKey( + @Validated(CreateGroup.class) @RequestBody SshKeyDTO sshKeyDTO, + @RequestHeader(HeaderParameter.ACCESS_TOKEN) String accessToken) { + checkSshKeyNameValidity(sshKeyDTO.name(), accessToken); + checkSshKeyPublicKeyValidity(sshKeyDTO.publicKey(), accessToken); + if (!sshKeyService.save(new SshKeyPO(sshKeyDTO, JwtUtil.getId(accessToken)))) { + throw new GenericException(ErrorCodeEnum.SSH_KEY_UPLOAD_FAILED, sshKeyDTO); + } + } + + @DeleteMapping(ApiPathConstant.SSH_KEY_DELETE_SSH_KEY_API_PATH) + @Operation( + summary = "Delete SSH key", + description = "Delete SSH key with the given information", + tags = {"SSH", "Delete Method"}) + @Parameters({ + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "id", + description = "SSH key ID", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Long.class)) + }) + @ApiResponse(responseCode = "200", description = "SSH key deleted successfully") + public void deleteSshKey( + @RequestHeader(HeaderParameter.ACCESS_TOKEN) String accessToken, + @RequestParam("id") Long id) { + var sshKeyPO = sshKeyService.getById(id); + if (sshKeyPO == null) { + throw new GenericException(ErrorCodeEnum.SSH_KEY_NOT_FOUND, id); + } + String idInToken = JwtUtil.getId(accessToken); + if (!idInToken.equals(sshKeyPO.getUserId().toString())) { + logger.info( + "User[{}] tried to delete SSH key of user[{}]", + idInToken, + sshKeyPO.getUserId()); + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); + } + if (!sshKeyService.removeById(id)) { + throw new GenericException(ErrorCodeEnum.SSH_KEY_DELETE_FAILED, id); + } + } + + @PostMapping(ApiPathConstant.SSH_KEY_UPDATE_SSH_KEY_API_PATH) + @Operation( + summary = "Update SSH key", + description = "Update SSH key with the given information", + tags = {"SSH", "Post Method"}) + @Parameters({ + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)) + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "SSH key updated successfully"), + @ApiResponse( + responseCode = "400", + description = "SSH key update failed", + content = @Content(schema = @Schema(implementation = ErrorVO.class))) + }) + public ResponseEntity updateSshKey( + @Validated(UpdateGroup.class) @RequestBody SshKeyDTO sshKeyDTO, + @RequestHeader(HeaderParameter.ACCESS_TOKEN) String accessToken) { + Long id = null; + try { + id = Long.valueOf(sshKeyDTO.id()); + } catch (NumberFormatException e) { + logger.error(e.getMessage()); + throw new GenericException(ErrorCodeEnum.MESSAGE_CONVERSION_ERROR); + } + var sshKeyPO = sshKeyService.getById(id); + if (sshKeyPO == null) { + throw new GenericException(ErrorCodeEnum.SSH_KEY_NOT_FOUND, id); + } + String idInToken = JwtUtil.getId(accessToken); + if (!idInToken.equals(sshKeyPO.getUserId().toString())) { + logger.info( + "User[{}] tried to update SSH key of user[{}]", + idInToken, + sshKeyPO.getUserId()); + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); + } + if (!sshKeyService.updateById(new SshKeyPO(sshKeyDTO))) { + throw new GenericException(ErrorCodeEnum.SSH_KEY_UPDATE_FAILED, sshKeyDTO); + } + return ResponseEntity.ok() + .body(new SshKeyVO(sshKeyService.getById(Long.valueOf(sshKeyDTO.id())))); + } + + @GetMapping(ApiPathConstant.SSH_KEY_PAGE_SSH_KEY_API_PATH) + @Operation( + summary = "Page SSH key", + description = "Page SSH key with the given information", + tags = {"SSH", "Get Method"}) + @Parameters({ + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "id", + description = "User ID", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Long.class)), + @Parameter( + name = "page", + description = "Page number", + example = "1", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Integer.class)), + @Parameter( + name = "size", + description = "Page size", + example = "10", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Integer.class)) + }) + @ApiResponse(responseCode = "200", description = "SSH key paged successfully") + public PageVO pageSshKey( + @RequestParam("id") Long userId, + @RequestParam("page") Integer page, + @RequestParam("size") Integer size) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("user_id", userId); + var iPage = sshKeyService.page(new Page<>(page, size), wrapper); + return new PageVO<>( + iPage.getPages(), + iPage.getTotal(), + iPage.getRecords().stream().map(SshKeyVO::new).toList()); + } + + @GetMapping(ApiPathConstant.SSH_KEY_CHECK_SSH_KEY_NAME_VALIDITY_API_PATH) + @Operation( + summary = "Check SSH key name validity", + description = "Check SSH key name validity with the given information", + tags = {"SSH", "Get Method"}) + @Parameters({ + @Parameter( + name = "name", + description = "SSH key name", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)), + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)), + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "SSH key name is valid"), + @ApiResponse( + responseCode = "400", + description = "SSH key name is invalid", + content = @Content(schema = @Schema(implementation = ErrorVO.class))) + }) + public void checkSshKeyNameValidity( + @RequestParam("name") + @Size( + min = ValidationConstant.MIN_SSH_KEY_NAME_LENGTH, + max = ValidationConstant.MAX_SSH_KEY_NAME_LENGTH, + message = "{Size.sshKeyController#checkSshKeyNameValidity.name}") + @NotBlank(message = "{NotBlank.sshKeyController#checkSshKeyNameValidity.name}") + String name, + @RequestHeader(HeaderParameter.ACCESS_TOKEN) String accessToken) { + Long idInToken = Long.valueOf(JwtUtil.getId(accessToken)); + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("user_id", idInToken); + wrapper.eq("name", name); + if (sshKeyService.exists(wrapper)) { + throw new GenericException(ErrorCodeEnum.SSH_KEY_NAME_ALREADY_EXISTS, name); + } + } + + @GetMapping(ApiPathConstant.SSH_KEY_CHECK_SSH_KEY_PUBLIC_KEY_VALIDITY_API_PATH) + @Operation( + summary = "Check SSH key public key validity", + description = "Check SSH key public key validity with the given information", + tags = {"SSH", "Get Method"}) + @Parameters({ + @Parameter( + name = "publicKey", + description = "SSH key public key", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)), + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)), + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "SSH key public key is valid"), + @ApiResponse( + responseCode = "400", + description = "SSH key public key is invalid", + content = @Content(schema = @Schema(implementation = ErrorVO.class))) + }) + public void checkSshKeyPublicKeyValidity( + @RequestParam("publicKey") + @Size( + min = ValidationConstant.MIN_SSH_KEY_PUBLIC_KEY_LENGTH, + max = ValidationConstant.MAX_SSH_KEY_PUBLIC_KEY_LENGTH, + message = + "{Size.sshKeyController#checkSshKeyPublicKeyValidity.publicKey}") + @NotBlank( + message = + "{NotBlank.sshKeyController#checkSshKeyPublicKeyValidity.publicKey}") + String publicKey, + @RequestHeader(HeaderParameter.ACCESS_TOKEN) String accessToken) { + boolean ok = true; + try { + Path tempFile = + Files.createTempFile(String.valueOf(System.currentTimeMillis()), ".pub"); + Files.writeString(tempFile, publicKey); + ProcessBuilder processBuilder = + new ProcessBuilder("ssh-keygen", "-lf", tempFile.toString()); + Process process = processBuilder.start(); + if (process.waitFor() != 0) { + ok = false; + } + tempFile.toFile().delete(); + } catch (Exception e) { + logger.error(e.getMessage()); + throw new GenericException(ErrorCodeEnum.SERVER_ERROR); + } + if (!ok) { + throw new GenericException(ErrorCodeEnum.SSH_KEY_PUBLIC_KEY_INVALID, publicKey); + } + Long idInToken = Long.valueOf(JwtUtil.getId(accessToken)); + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("user_id", idInToken); + wrapper.eq("public_key", publicKey); + if (sshKeyService.exists(wrapper)) { + throw new GenericException(ErrorCodeEnum.SSH_KEY_PUBLIC_KEY_ALREADY_EXISTS, publicKey); + } + } +} diff --git a/src/main/java/edu/cmipt/gcs/controller/UserController.java b/src/main/java/edu/cmipt/gcs/controller/UserController.java new file mode 100644 index 0000000..89607ed --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/controller/UserController.java @@ -0,0 +1,439 @@ +package edu.cmipt.gcs.controller; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; + +import edu.cmipt.gcs.constant.ApiPathConstant; +import edu.cmipt.gcs.constant.HeaderParameter; +import edu.cmipt.gcs.constant.ValidationConstant; +import edu.cmipt.gcs.enumeration.ErrorCodeEnum; +import edu.cmipt.gcs.exception.GenericException; +import edu.cmipt.gcs.pojo.error.ErrorVO; +import edu.cmipt.gcs.pojo.user.UserCreateDTO; +import edu.cmipt.gcs.pojo.user.UserPO; +import edu.cmipt.gcs.pojo.user.UserUpdateDTO; +import edu.cmipt.gcs.pojo.user.UserVO; +import edu.cmipt.gcs.service.UserService; +import edu.cmipt.gcs.util.EmailVerificationCodeUtil; +import edu.cmipt.gcs.util.JwtUtil; +import edu.cmipt.gcs.util.MD5Converter; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Set; + +@Validated +@RestController +@Tag(name = "User", description = "User Related APIs") +public class UserController { + @Autowired private UserService userService; + + private Set reservedUsernames = + Set.of( + "new", + "settings", + "login", + "logout", + "admin", + "signup", + "testing", + "gitolite-admin"); + + @PostMapping(ApiPathConstant.USER_CREATE_USER_API_PATH) + @Operation( + summary = "Create a user", + description = "Create a user with the given information", + tags = {"User", "Post Method"}) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "User created successfully"), + @ApiResponse( + responseCode = "400", + description = "User created failed", + content = @Content(schema = @Schema(implementation = ErrorVO.class))), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + public void createUser(@Validated @RequestBody UserCreateDTO user) { + checkUsernameValidity(user.username()); + checkEmailValidity(user.email()); + if (!EmailVerificationCodeUtil.verifyVerificationCode( + user.email(), user.emailVerificationCode())) { + throw new GenericException( + ErrorCodeEnum.INVALID_EMAIL_VERIFICATION_CODE, user.emailVerificationCode()); + } + boolean res = userService.save(new UserPO(user)); + if (!res) { + throw new GenericException(ErrorCodeEnum.USER_CREATE_FAILED, user); + } + } + + @GetMapping(ApiPathConstant.USER_GET_USER_API_PATH) + @Operation( + summary = "Get a user", + description = "Get a user's information", + tags = {"User", "Get Method"}) + @Parameters({ + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "user", + description = "User's Information", + example = "admin", + required = false, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "userType", + description = "User's Type. The value can be 'id', 'username', 'email', or 'token'", + example = "username", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)) + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "User information returned successfully"), + @ApiResponse( + responseCode = "404", + description = "User not found", + content = @Content(schema = @Schema(implementation = ErrorVO.class))) + }) + public UserVO getUser( + @RequestParam(name = "user", required = false) String user, + @RequestParam("userType") String userType, + @RequestHeader(HeaderParameter.ACCESS_TOKEN) String accessToken) { + // TODO: + // Use a cutomized type to replace the String type + if (!userType.equals("id") + && !userType.equals("username") + && !userType.equals("email") + && !userType.equals("token")) { + throw new GenericException(ErrorCodeEnum.MESSAGE_CONVERSION_ERROR); + } + QueryWrapper wrapper = new QueryWrapper(); + if (userType.equals("id")) { + if (user == null) { + throw new GenericException(ErrorCodeEnum.MESSAGE_CONVERSION_ERROR); + } + try { + Long id = Long.valueOf(user); + wrapper.eq("id", id); + } catch (Exception e) { + throw new GenericException(ErrorCodeEnum.MESSAGE_CONVERSION_ERROR); + } + } else if (userType.equals("token")) { + Long idInToken = Long.valueOf(JwtUtil.getId(accessToken)); + wrapper.eq("id", idInToken); + } else { + wrapper.apply("LOWER(" + userType + ") = LOWER({0})", user); + } + if (!userService.exists(wrapper)) { + throw new GenericException(ErrorCodeEnum.USER_NOT_FOUND, user); + } + return new UserVO(userService.getOne(wrapper)); + } + + @PostMapping(ApiPathConstant.USER_UPDATE_USER_API_PATH) + @Operation( + summary = "Update user", + description = "Update user information", + tags = {"User", "Post Method"}) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "User information updated successfully"), + @ApiResponse( + responseCode = "400", + description = "User information update failed", + content = @Content(schema = @Schema(implementation = ErrorVO.class))) + }) + @Parameters({ + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)), + @Parameter( + name = HeaderParameter.REFRESH_TOKEN, + description = "Refresh token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)) + }) + public ResponseEntity updateUser(@Validated @RequestBody UserUpdateDTO user) { + if (user.username() != null) { + throw new GenericException(ErrorCodeEnum.OPERATION_NOT_IMPLEMENTED); + } + // for the null fields, mybatis-plus will ignore by default + if (!userService.updateById(new UserPO(user))) { + throw new GenericException(ErrorCodeEnum.USER_UPDATE_FAILED, user); + } + UserVO userVO = new UserVO(userService.getById(Long.valueOf(user.id()))); + return ResponseEntity.ok().body(userVO); + } + + // TODO: use request body to pass the parameters + @PostMapping(ApiPathConstant.USER_UPDATE_USER_PASSWORD_WITH_OLD_PASSWORD_API_PATH) + @Operation( + summary = "Update user password", + description = "Update user password", + tags = {"User", "Post Method"}) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "User password updated successfully"), + @ApiResponse( + responseCode = "400", + description = "User password update failed", + content = @Content(schema = @Schema(implementation = ErrorVO.class))) + }) + @Parameters({ + @Parameter( + name = "id", + description = "User ID", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "oldPassword", + description = "Old password", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "newPassword", + description = "New password", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)) + }) + public void updateUserPasswordWithOldPassword( + @RequestParam("id") Long id, + @RequestParam("oldPassword") String oldPassword, + @RequestParam("newPassword") String newPassword) { + UpdateWrapper wrapper = new UpdateWrapper(); + wrapper.eq("id", id); + wrapper.eq("user_password", MD5Converter.convertToMD5(oldPassword)); + if (!userService.exists(wrapper)) { + throw new GenericException(ErrorCodeEnum.WRONG_UPDATE_PASSWORD_INFORMATION); + } + checkPasswordValidity(newPassword); + wrapper.set("user_password", MD5Converter.convertToMD5(newPassword)); + if (!userService.update(wrapper)) { + throw new GenericException(ErrorCodeEnum.USER_UPDATE_FAILED, newPassword); + } + JwtUtil.blacklistToken(id); + } + + @PostMapping(ApiPathConstant.USER_UPDATE_USER_PASSWORD_WITH_EMAIL_VERIFICATION_CODE_API_PATH) + @Operation( + summary = "Update user password with email verification code", + description = "Update user password with email verification code", + tags = {"User", "Post Method"}) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "User password updated successfully"), + @ApiResponse( + responseCode = "400", + description = "User password update failed", + content = @Content(schema = @Schema(implementation = ErrorVO.class))) + }) + @Parameters({ + @Parameter( + name = "email", + description = "Email", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "emailVerificationCode", + description = "Email verification code", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "newPassword", + description = "New password", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)) + }) + public void updateUserPasswordWithEmailVerificationCode( + @RequestParam("email") String email, + @RequestParam("emailVerificationCode") String emailVerificationCode, + @RequestParam("newPassword") String newPassword) { + if (!EmailVerificationCodeUtil.verifyVerificationCode(email, emailVerificationCode)) { + throw new GenericException( + ErrorCodeEnum.INVALID_EMAIL_VERIFICATION_CODE, emailVerificationCode); + } + if (!userService.emailExists(email)) { + throw new GenericException(ErrorCodeEnum.USER_NOT_FOUND, email); + } + checkPasswordValidity(newPassword); + UpdateWrapper wrapper = new UpdateWrapper(); + wrapper.apply("LOWER(email) = LOWER({0})", email); + wrapper.set("user_password", MD5Converter.convertToMD5(newPassword)); + if (!userService.update(wrapper)) { + throw new GenericException(ErrorCodeEnum.USER_UPDATE_FAILED, email); + } + JwtUtil.blacklistToken(userService.getOne(wrapper).getId()); + } + + @DeleteMapping(ApiPathConstant.USER_DELETE_USER_API_PATH) + @Operation( + summary = "Delete user", + description = "Delete user by id", + tags = {"User", "Delete Method"}) + @Parameters({ + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "id", + description = "User id", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Long.class)) + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "User deleted successfully"), + @ApiResponse( + responseCode = "404", + description = "User not found", + content = @Content(schema = @Schema(implementation = ErrorVO.class))) + }) + public void deleteUser(@RequestParam("id") Long id) { + // do not support delete user by now + throw new GenericException(ErrorCodeEnum.OPERATION_NOT_IMPLEMENTED); + // if (userService.getById(id) == null) { + // throw new GenericException(ErrorCodeEnum.USER_NOT_FOUND, id); + // } + // if (!userService.removeById(id)) { + // throw new GenericException(ErrorCodeEnum.USER_DELETE_FAILED, id); + // } + // JwtUtil.blacklistToken(id); + } + + @GetMapping(ApiPathConstant.USER_CHECK_EMAIL_VALIDITY_API_PATH) + @Operation( + summary = "Check email validity", + description = "Check if the email is valid", + tags = {"User", "Get Method"}) + @Parameter( + name = "email", + description = "Email", + example = "admin@cmipt.edu", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Email validity checked successfully"), + @ApiResponse( + responseCode = "400", + description = "Email is invalid", + content = @Content(schema = @Schema(implementation = ErrorVO.class))) + }) + public void checkEmailValidity( + @RequestParam("email") + @Email(message = "{Email.userController#checkEmailValidity.email}") + @NotBlank(message = "{NotBlank.userController#checkEmailValidity.email}") + String email) { + if (userService.emailExists(email)) { + throw new GenericException(ErrorCodeEnum.EMAIL_ALREADY_EXISTS, email); + } + } + + @GetMapping(ApiPathConstant.USER_CHECK_USERNAME_VALIDITY_API_PATH) + @Operation( + summary = "Check username validity", + description = "Check if the username is valid", + tags = {"User", "Get Method"}) + @Parameter( + name = "username", + description = "User name", + example = "admin", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Username validity checked successfully"), + @ApiResponse( + responseCode = "400", + description = "Username is not valid", + content = @Content(schema = @Schema(implementation = ErrorVO.class))) + }) + public void checkUsernameValidity( + @RequestParam("username") + @Size( + min = ValidationConstant.MIN_USERNAME_LENGTH, + max = ValidationConstant.MAX_USERNAME_LENGTH, + message = "{Size.userController#checkUsernameValidity.username}") + @NotBlank(message = "{NotBlank.userController#checkUsernameValidity.username}") + @Pattern( + regexp = ValidationConstant.USERNAME_PATTERN, + message = "{Pattern.userController#checkUsernameValidity.username}") + String username) { + if (reservedUsernames.contains(username)) { + throw new GenericException(ErrorCodeEnum.USERNAME_RESERVED, username); + } + if (userService.usernameExists(username)) { + throw new GenericException(ErrorCodeEnum.USERNAME_ALREADY_EXISTS, username); + } + } + + @GetMapping(ApiPathConstant.USER_CHECK_USER_PASSWORD_VALIDITY_API_PATH) + @Operation( + summary = "Check password validity", + description = "Check if the password is valid", + tags = {"User", "Get Method"}) + @Parameter( + name = "userPassword", + description = "User's Password", + example = "123456", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Password validity checked successfully"), + @ApiResponse( + responseCode = "400", + description = "Password is not valid", + content = @Content(schema = @Schema(implementation = ErrorVO.class))) + }) + public void checkPasswordValidity( + @RequestParam("userPassword") + @Size( + min = ValidationConstant.MIN_PASSWORD_LENGTH, + max = ValidationConstant.MAX_PASSWORD_LENGTH, + message = "{Size.userController#checkPasswordValidity.password}") + @NotBlank(message = "{NotBlank.userController#checkPasswordValidity.password}") + @Pattern( + regexp = ValidationConstant.PASSWORD_PATTERN, + message = "{Pattern.userController#checkPasswordValidity.password}") + String password) {} +} diff --git a/src/main/java/edu/cmipt/gcs/dao/RepositoryMapper.java b/src/main/java/edu/cmipt/gcs/dao/RepositoryMapper.java new file mode 100644 index 0000000..f543bf0 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/dao/RepositoryMapper.java @@ -0,0 +1,7 @@ +package edu.cmipt.gcs.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +import edu.cmipt.gcs.pojo.repository.RepositoryPO; + +public interface RepositoryMapper extends BaseMapper {} diff --git a/src/main/java/edu/cmipt/gcs/dao/SshKeyMapper.java b/src/main/java/edu/cmipt/gcs/dao/SshKeyMapper.java new file mode 100644 index 0000000..9434b85 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/dao/SshKeyMapper.java @@ -0,0 +1,7 @@ +package edu.cmipt.gcs.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +import edu.cmipt.gcs.pojo.ssh.SshKeyPO; + +public interface SshKeyMapper extends BaseMapper {} diff --git a/src/main/java/edu/cmipt/gcs/dao/TriggerForMetaObject.java b/src/main/java/edu/cmipt/gcs/dao/TriggerForMetaObject.java new file mode 100644 index 0000000..000436d --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/dao/TriggerForMetaObject.java @@ -0,0 +1,23 @@ +package edu.cmipt.gcs.dao; + +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; + +import org.apache.ibatis.reflection.MetaObject; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Component +public class TriggerForMetaObject implements MetaObjectHandler { + + @Override + public void insertFill(MetaObject metaObject) { + this.setFieldValByName("gmtCreated", LocalDateTime.now(), metaObject); + this.setFieldValByName("gmtUpdated", LocalDateTime.now(), metaObject); + } + + @Override + public void updateFill(MetaObject metaObject) { + this.setFieldValByName("gmtUpdated", LocalDateTime.now(), metaObject); + } +} diff --git a/src/main/java/edu/cmipt/gcs/dao/UserCollaborateRepositoryMapper.java b/src/main/java/edu/cmipt/gcs/dao/UserCollaborateRepositoryMapper.java new file mode 100644 index 0000000..228edd4 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/dao/UserCollaborateRepositoryMapper.java @@ -0,0 +1,7 @@ +package edu.cmipt.gcs.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +import edu.cmipt.gcs.pojo.collaboration.UserCollaborateRepositoryPO; + +public interface UserCollaborateRepositoryMapper extends BaseMapper {} diff --git a/src/main/java/edu/cmipt/gcs/dao/UserMapper.java b/src/main/java/edu/cmipt/gcs/dao/UserMapper.java new file mode 100644 index 0000000..8c2ff9c --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/dao/UserMapper.java @@ -0,0 +1,7 @@ +package edu.cmipt.gcs.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +import edu.cmipt.gcs.pojo.user.UserPO; + +public interface UserMapper extends BaseMapper {} diff --git a/src/main/java/edu/cmipt/gcs/enumeration/ErrorCodeEnum.java b/src/main/java/edu/cmipt/gcs/enumeration/ErrorCodeEnum.java new file mode 100644 index 0000000..f489e21 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/enumeration/ErrorCodeEnum.java @@ -0,0 +1,66 @@ +package edu.cmipt.gcs.enumeration; + +public enum ErrorCodeEnum { + // This should be ignored, this is to make the ordinal of the enum start from 1 + ZERO_PLACEHOLDER, + + VALIDATION_ERROR("VALIDATION_ERROR"), + + USERNAME_RESERVED("USERNAME_RESERVED"), + USERNAME_ALREADY_EXISTS("USERNAME_ALREADY_EXISTS"), + EMAIL_ALREADY_EXISTS("EMAIL_ALREADY_EXISTS"), + WRONG_SIGN_IN_INFORMATION("WRONG_SIGN_IN_INFORMATION"), + + INVALID_TOKEN("INVALID_TOKEN"), + ACCESS_DENIED("ACCESS_DENIED"), + TOKEN_NOT_FOUND("TOKEN_NOT_FOUND"), + + MESSAGE_CONVERSION_ERROR("MESSAGE_CONVERSION_ERROR"), + + USER_NOT_FOUND("USER_NOT_FOUND"), + + USER_CREATE_FAILED("USER_CREATE_FAILED"), + USER_UPDATE_FAILED("USER_UPDATE_FAILED"), + USER_DELETE_FAILED("USER_DELETE_FAILED"), + WRONG_UPDATE_PASSWORD_INFORMATION("WRONG_UPDATE_PASSWORD_INFORMATION"), + + REPOSITORY_NOT_FOUND("REPOSITORY_NOT_FOUND"), + REPOSITORY_ALREADY_EXISTS("REPOSITORY_ALREADY_EXISTS"), + REPOSITORY_CREATE_FAILED("REPOSITORY_CREATE_FAILED"), + REPOSITORY_UPDATE_FAILED("REPOSITORY_UPDATE_FAILED"), + REPOSITORY_DELETE_FAILED("REPOSITORY_DELETE_FAILED"), + + COLLABORATION_ADD_FAILED("COLLABORATION_ADD_FAILED"), + COLLABORATION_REMOVE_FAILED("COLLABORATION_REMOVE_FAILED"), + COLLABORATION_ALREADY_EXISTS("COLLABORATION_ALREADY_EXISTS"), + COLLABORATION_NOT_FOUND("COLLABORATION_NOT_FOUND"), + + SSH_KEY_UPLOAD_FAILED("SSH_KEY_UPLOAD_FAILED"), + SSH_KEY_UPDATE_FAILED("SSH_KEY_UPDATE_FAILED"), + SSH_KEY_DELETE_FAILED("SSH_KEY_DELETE_FAILED"), + SSH_KEY_NOT_FOUND("SSH_KEY_NOT_FOUND"), + SSH_KEY_PUBLIC_KEY_INVALID("SSH_KEY_PUBLIC_KEY_INVALID"), + SSH_KEY_PUBLIC_KEY_ALREADY_EXISTS("SSH_KEY_PUBLIC_KEY_ALREADY_EXISTS"), + SSH_KEY_NAME_ALREADY_EXISTS("SSH_KEY_NAME_ALREADY_EXISTS"), + + OPERATION_NOT_IMPLEMENTED("OPERATION_NOT_IMPLEMENTED"), + + SERVER_ERROR("SERVER_ERROR"), + + ILLOGICAL_OPERATION("ILLOGICAL_OPERATION"), + + INVALID_EMAIL_VERIFICATION_CODE("INVALID_EMAIL_VERIFICATION_CODE"); + + // code means the error code in the message.properties + private String code; + + ErrorCodeEnum() {} + + ErrorCodeEnum(String code) { + this.code = code; + } + + public String getCode() { + return code; + } +} diff --git a/src/main/java/edu/cmipt/gcs/enumeration/TokenTypeEnum.java b/src/main/java/edu/cmipt/gcs/enumeration/TokenTypeEnum.java new file mode 100644 index 0000000..7448322 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/enumeration/TokenTypeEnum.java @@ -0,0 +1,6 @@ +package edu.cmipt.gcs.enumeration; + +public enum TokenTypeEnum { + ACCESS_TOKEN, + REFRESH_TOKEN +} diff --git a/src/main/java/edu/cmipt/gcs/exception/GenericException.java b/src/main/java/edu/cmipt/gcs/exception/GenericException.java new file mode 100644 index 0000000..a050e45 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/exception/GenericException.java @@ -0,0 +1,32 @@ +package edu.cmipt.gcs.exception; + +import edu.cmipt.gcs.enumeration.ErrorCodeEnum; +import edu.cmipt.gcs.util.MessageSourceUtil; + +import lombok.Getter; +import lombok.Setter; + +/** + * GenericException + * + *

Generic exception class + * + *

This exception is for the errors should be pass to the client, and the error code should be + * defined in {@link ErrorCodeEnum}. All bussiness error should throw this exception. + * + * @author Kaiser + */ +@Getter +@Setter +public class GenericException extends RuntimeException { + private ErrorCodeEnum code; + + public GenericException(String message) { + super(message); + } + + public GenericException(ErrorCodeEnum code, Object... args) { + super(MessageSourceUtil.getMessage(code, args)); + this.code = code; + } +} diff --git a/src/main/java/edu/cmipt/gcs/exception/GlobalExceptionHandler.java b/src/main/java/edu/cmipt/gcs/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..8db5a9f --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/exception/GlobalExceptionHandler.java @@ -0,0 +1,119 @@ +package edu.cmipt.gcs.exception; + +import edu.cmipt.gcs.enumeration.ErrorCodeEnum; +import edu.cmipt.gcs.pojo.error.ErrorVO; +import edu.cmipt.gcs.util.MessageSourceUtil; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.json.JsonParseException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * GlobalExceptionHandler Handles exceptions globally + * + * @author: Kaiser + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + /** + * Handles MethodArgumentNotValidException + * + *

This method is used to handle the MethodArgumentNotValidException, which is thrown when + * the validation of the request body fails. + * + * @param e MethodArgumentNotValidException + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException e, HttpServletRequest request) { + var fieldError = e.getBindingResult().getFieldError(); + return handleValidationException( + MessageSourceUtil.getMessage(fieldError.getCodes()[0], fieldError.getArguments()), + request); + } + + /** + * Handles ConstraintViolationException + * + *

This method is used to handle the ConstraintViolationException, which is thrown when the + * validation of the path variables or request parameters fails. + * + * @param e ConstraintViolationException + */ + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException( + ConstraintViolationException e, HttpServletRequest request) { + return handleValidationException( + e.getConstraintViolations().iterator().next().getMessage(), request); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException e, HttpServletRequest request) { + return handleGenericException( + new GenericException(ErrorCodeEnum.MESSAGE_CONVERSION_ERROR), request); + } + + @ExceptionHandler(GenericException.class) + public ResponseEntity handleGenericException( + GenericException e, HttpServletRequest request) { + logger.error("Error caused by {}:\n {}", request.getRemoteAddr(), e.getMessage()); + switch (e.getCode()) { + case INVALID_TOKEN: + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new ErrorVO(e.getCode(), e.getMessage())); + case ACCESS_DENIED: + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(new ErrorVO(e.getCode(), e.getMessage())); + case USER_NOT_FOUND: + case SSH_KEY_NOT_FOUND: + case REPOSITORY_NOT_FOUND: + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ErrorVO(e.getCode(), e.getMessage())); + case OPERATION_NOT_IMPLEMENTED: + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED) + .body(new ErrorVO(e.getCode(), e.getMessage())); + case SERVER_ERROR: + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorVO(e.getCode(), e.getMessage())); + default: + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorVO(e.getCode(), e.getMessage())); + } + } + + @ExceptionHandler(JsonParseException.class) + public ResponseEntity handleJsonParseException( + JsonParseException e, HttpServletRequest request) { + GenericException exception = new GenericException(e.getMessage()); + exception.setCode(ErrorCodeEnum.MESSAGE_CONVERSION_ERROR); + return handleGenericException(exception, request); + } + + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e, HttpServletRequest request) { + logger.error(e.getMessage()); + // TODO: use logger to log the exception + e.printStackTrace(); + return handleGenericException(new GenericException(ErrorCodeEnum.SERVER_ERROR), request); + } + + private ResponseEntity handleValidationException( + String message, HttpServletRequest request) { + return handleGenericException( + new GenericException(ErrorCodeEnum.VALIDATION_ERROR, message), request); + } +} diff --git a/src/main/java/edu/cmipt/gcs/filter/ExceptionHandlerFiter.java b/src/main/java/edu/cmipt/gcs/filter/ExceptionHandlerFiter.java new file mode 100644 index 0000000..8f0ee55 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/filter/ExceptionHandlerFiter.java @@ -0,0 +1,49 @@ +package edu.cmipt.gcs.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.servlet.HandlerExceptionResolver; + +import java.io.IOException; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +/** + * ExceptionHandlerFiter + * + *

Filter to handle exceptions globally + * + *

We find that the exception thrown by filters will not handled by the handler annotated with + * {@link ControllerAdvice @ControllerAdvice} or {@link RestControllerAdvice @RestControllerAdvice}, + * this is because the exception thrown by filters is not thrown by the controller method. In order + * to solve this, we can use {@link HandlerExceptionResolver} to handle the exception thrown by + * filters, which will be resolved by the handler annotated with {@link + * ControllerAdvice @ControllerAdvice} or {@link RestControllerAdvice @RestControllerAdvice}. + * + * @author Kaiser + */ +public class ExceptionHandlerFiter extends OncePerRequestFilter { + @Autowired + @Qualifier("handlerExceptionResolver") + private HandlerExceptionResolver resolver; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (Exception e) { + resolver.resolveException(request, response, null, e); + } + } +} diff --git a/src/main/java/edu/cmipt/gcs/filter/JwtFilter.java b/src/main/java/edu/cmipt/gcs/filter/JwtFilter.java new file mode 100644 index 0000000..ca4a310 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/filter/JwtFilter.java @@ -0,0 +1,271 @@ +package edu.cmipt.gcs.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import edu.cmipt.gcs.constant.ApiPathConstant; +import edu.cmipt.gcs.constant.HeaderParameter; +import edu.cmipt.gcs.enumeration.ErrorCodeEnum; +import edu.cmipt.gcs.enumeration.TokenTypeEnum; +import edu.cmipt.gcs.exception.GenericException; +import edu.cmipt.gcs.pojo.user.UserUpdateDTO; +import edu.cmipt.gcs.util.JwtUtil; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.util.StreamUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.Map; +import java.util.Set; + +/** + * JwtFilter + * + *

Filter to check the validity of the Access-Token + * + * @author Kaiser + */ +@Component +@Order(Ordered.LOWEST_PRECEDENCE) +public class JwtFilter extends OncePerRequestFilter { + @Autowired ObjectMapper objectMapper; + + /** + * CachedBodyHttpServletRequest + * + *

The {@link}getInputStream() and {@link}getReader() methods of {@link}HttpServletRequest + * can only be called once. This class is used to cache the body of the request so that it can + * be read multiple times. + */ + private class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { + private class CachedBodyServletInputStream extends ServletInputStream { + private final InputStream cacheBodyInputStream; + + public CachedBodyServletInputStream(byte[] cacheBody) { + this.cacheBodyInputStream = new ByteArrayInputStream(cacheBody); + } + + @Override + public boolean isFinished() { + try { + return cacheBodyInputStream.available() == 0; + } catch (IOException e) { + return true; + } + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener listener) { + throw new UnsupportedOperationException(); + } + + @Override + public int read() throws IOException { + return cacheBodyInputStream.read(); + } + } + + private final byte[] cacheBody; + + public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException { + super(request); + InputStream requestInputStream = request.getInputStream(); + this.cacheBody = StreamUtils.copyToByteArray(requestInputStream); + } + + @Override + public ServletInputStream getInputStream() { + return new CachedBodyServletInputStream(this.cacheBody); + } + + @Override + public BufferedReader getReader() { + return new BufferedReader( + new InputStreamReader(new ByteArrayInputStream(this.cacheBody))); + } + } + + private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class); + + // Paths that do not need token + private Map> ignorePath = + Map.of( + "GET", + Set.of( + ApiPathConstant.DEVELOPMENT_GET_API_MAP_API_PATH, + ApiPathConstant.DEVELOPMENT_GET_ERROR_MESSAGE_API_PATH, + ApiPathConstant.DEVELOPMENT_GET_VO_AS_TS_API_PATH, + ApiPathConstant.USER_CHECK_USERNAME_VALIDITY_API_PATH, + ApiPathConstant.USER_CHECK_USER_PASSWORD_VALIDITY_API_PATH, + ApiPathConstant.USER_CHECK_EMAIL_VALIDITY_API_PATH, + ApiPathConstant + .REPOSITORY_CHECK_REPOSITORY_NAME_VALIDITY_API_PATH, + ApiPathConstant + .AUTHENTICATION_SEND_EMAIL_VERIFICATION_CODE_API_PATH), + "POST", + Set.of( + ApiPathConstant.AUTHENTICATION_SIGN_IN_API_PATH, + ApiPathConstant.USER_CREATE_USER_API_PATH, + ApiPathConstant + .USER_UPDATE_USER_PASSWORD_WITH_OLD_PASSWORD_API_PATH, + ApiPathConstant + .USER_UPDATE_USER_PASSWORD_WITH_EMAIL_VERIFICATION_CODE_API_PATH), + "DELETE", Set.of(ApiPathConstant.AUTHENTICATION_SIGN_OUT_API_PATH)); + + // Paths that do not need authorization in filter + private Map> passPath = + Map.of( + "GET", + Set.of( + ApiPathConstant.AUTHENTICATION_REFRESH_API_PATH, + ApiPathConstant + .SSH_KEY_CHECK_SSH_KEY_PUBLIC_KEY_VALIDITY_API_PATH, + ApiPathConstant.SSH_KEY_CHECK_SSH_KEY_NAME_VALIDITY_API_PATH, + ApiPathConstant.REPOSITORY_PAGE_REPOSITORY_API_PATH, + ApiPathConstant.USER_GET_USER_API_PATH, + ApiPathConstant.REPOSITORY_GET_REPOSITORY_API_PATH, + ApiPathConstant.REPOSITORY_PAGE_COLLABORATOR_API_PATH), + "POST", + Set.of( + ApiPathConstant.REPOSITORY_CREATE_REPOSITORY_API_PATH, + ApiPathConstant.REPOSITORY_UPDATE_REPOSITORY_API_PATH, + ApiPathConstant.REPOSITORY_ADD_COLLABORATOR_API_PATH, + ApiPathConstant.SSH_KEY_UPLOAD_SSH_KEY_API_PATH, + ApiPathConstant.SSH_KEY_UPDATE_SSH_KEY_API_PATH), + "DELETE", + Set.of( + ApiPathConstant.REPOSITORY_DELETE_REPOSITORY_API_PATH, + ApiPathConstant.REPOSITORY_REMOVE_COLLABORATION_API_PATH, + ApiPathConstant.SSH_KEY_DELETE_SSH_KEY_API_PATH)); + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + // ignore non business api and some special api + var ignoreSet = ignorePath.get(request.getMethod()); + if (!request.getRequestURI().startsWith(ApiPathConstant.ALL_API_PREFIX) + || ignoreSet != null && ignoreSet.contains(request.getRequestURI())) { + filterChain.doFilter(request, response); + return; + } + // throw exception if authorization failed + CachedBodyHttpServletRequest cachedRequest = new CachedBodyHttpServletRequest(request); + authorize( + cachedRequest, + cachedRequest.getHeader(HeaderParameter.ACCESS_TOKEN), + cachedRequest.getHeader(HeaderParameter.REFRESH_TOKEN)); + filterChain.doFilter(cachedRequest, response); + } + + private void authorize(HttpServletRequest request, String accessToken, String refreshToken) { + if (accessToken != null && JwtUtil.getTokenType(accessToken) != TokenTypeEnum.ACCESS_TOKEN + || refreshToken != null + && JwtUtil.getTokenType(refreshToken) != TokenTypeEnum.REFRESH_TOKEN) { + throw new GenericException(ErrorCodeEnum.INVALID_TOKEN, accessToken); + } + var requestURI = request.getRequestURI(); + var requestMethod = request.getMethod(); + var passSet = passPath.get(requestMethod); + if (passSet != null && passSet.contains(requestURI)) { + if (requestURI.equals(ApiPathConstant.AUTHENTICATION_REFRESH_API_PATH)) { + if (refreshToken == null) { + throw new GenericException(ErrorCodeEnum.TOKEN_NOT_FOUND); + } + JwtUtil.refreshToken(refreshToken); + } else { + if (accessToken == null) { + throw new GenericException(ErrorCodeEnum.TOKEN_NOT_FOUND); + } + JwtUtil.refreshToken(accessToken); + } + return; + } + if (accessToken == null) { + throw new GenericException(ErrorCodeEnum.TOKEN_NOT_FOUND); + } + switch (requestMethod) { + case "GET": + if (requestURI.equals(ApiPathConstant.SSH_KEY_PAGE_SSH_KEY_API_PATH)) { + String idInToken = JwtUtil.getId(accessToken); + String idInParam = request.getParameter("id"); + if (!idInToken.equals(idInParam)) { + logger.info( + "User[{}] tried to get SSH key of user[{}]", idInToken, idInParam); + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); + } + } else { + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); + } + break; + case "POST": + if (requestURI.equals(ApiPathConstant.USER_UPDATE_USER_API_PATH)) { + // User can not update other user's information + String idInToken = JwtUtil.getId(accessToken); + String idInBody = getIdFromRequestBody(request); + if (!idInToken.equals(idInBody)) { + logger.info("User[{}] tried to update user[{}]", idInToken, idInBody); + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); + } + } else { + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); + } + break; + case "DELETE": + if (requestURI.equals(ApiPathConstant.USER_DELETE_USER_API_PATH)) { + // User can not delete other user + String idInToken = JwtUtil.getId(accessToken); + String idInParam = request.getParameter("id"); + if (!idInToken.equals(idInParam)) { + logger.info("User[{}] tried to delete user[{}]", idInToken, idInParam); + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); + } + } else { + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); + } + break; + default: + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); + } + JwtUtil.refreshToken(accessToken); + } + + private String getIdFromRequestBody(HttpServletRequest request) { + try { + BufferedReader reader = request.getReader(); + StringBuilder builder = new StringBuilder(); + String line = reader.readLine(); + while (line != null) { + builder.append(line); + line = reader.readLine(); + } + reader.close(); + return objectMapper.readValue(builder.toString(), UserUpdateDTO.class).id(); + } catch (Exception e) { + // unlikely to happen + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/edu/cmipt/gcs/pojo/collaboration/UserCollaborateRepositoryPO.java b/src/main/java/edu/cmipt/gcs/pojo/collaboration/UserCollaborateRepositoryPO.java new file mode 100644 index 0000000..aa11053 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/pojo/collaboration/UserCollaborateRepositoryPO.java @@ -0,0 +1,35 @@ +package edu.cmipt.gcs.pojo.collaboration; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@AllArgsConstructor +@TableName("t_user_collaborate_repository") +@NoArgsConstructor +public class UserCollaborateRepositoryPO { + private Long id; + private Long collaboratorId; + private Long repositoryId; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime gmtCreated; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime gmtUpdated; + + @TableLogic private LocalDateTime gmtDeleted; + + public UserCollaborateRepositoryPO(Long collaboratorId, Long repositoryId) { + this.collaboratorId = collaboratorId; + this.repositoryId = repositoryId; + } +} diff --git a/src/main/java/edu/cmipt/gcs/pojo/error/ErrorVO.java b/src/main/java/edu/cmipt/gcs/pojo/error/ErrorVO.java new file mode 100644 index 0000000..4e4e0dc --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/pojo/error/ErrorVO.java @@ -0,0 +1,14 @@ +package edu.cmipt.gcs.pojo.error; + +import edu.cmipt.gcs.enumeration.ErrorCodeEnum; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Error response") +public record ErrorVO( + @Schema(description = "Error code") Integer code, + @Schema(description = "Error message") String message) { + public ErrorVO(ErrorCodeEnum errorCodeEnum, String message) { + this(errorCodeEnum.ordinal(), message); + } +} diff --git a/src/main/java/edu/cmipt/gcs/pojo/other/PageVO.java b/src/main/java/edu/cmipt/gcs/pojo/other/PageVO.java new file mode 100644 index 0000000..38a2175 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/pojo/other/PageVO.java @@ -0,0 +1,11 @@ +package edu.cmipt.gcs.pojo.other; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "Page Value Object") +public record PageVO( + @Schema(description = "Total Pages") Long pages, + @Schema(description = "Total Records") Long total, + @Schema(description = "Records for Current Page") List records) {} diff --git a/src/main/java/edu/cmipt/gcs/pojo/repository/RepositoryDTO.java b/src/main/java/edu/cmipt/gcs/pojo/repository/RepositoryDTO.java new file mode 100644 index 0000000..cbcf94f --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/pojo/repository/RepositoryDTO.java @@ -0,0 +1,41 @@ +package edu.cmipt.gcs.pojo.repository; + +import edu.cmipt.gcs.constant.ValidationConstant; +import edu.cmipt.gcs.validation.group.CreateGroup; +import edu.cmipt.gcs.validation.group.UpdateGroup; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Null; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +@Schema(description = "Repository Data Transfer Object") +public record RepositoryDTO( + @Schema(description = "Repository ID") + @Null(groups = CreateGroup.class) + @NotNull(groups = UpdateGroup.class) + String id, + @Schema( + description = "Repository Name", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "gcs") + @Size( + groups = {CreateGroup.class, UpdateGroup.class}, + min = ValidationConstant.MIN_REPOSITORY_NAME_LENGTH, + max = ValidationConstant.MAX_REPOSITORY_NAME_LENGTH) + @NotBlank(groups = CreateGroup.class) + @Pattern( + regexp = ValidationConstant.REPOSITORY_NAME_PATTERN, + groups = {CreateGroup.class, UpdateGroup.class}) + String repositoryName, + @Schema(description = "Repository Description") + @Size( + groups = {CreateGroup.class, UpdateGroup.class}, + min = ValidationConstant.MIN_REPOSITORY_DESCRIPTION_LENGTH, + max = ValidationConstant.MAX_REPOSITORY_DESCRIPTION_LENGTH) + String repositoryDescription, + @Schema(description = "Whether or Not Private Repo", example = "false") + Boolean isPrivate) {} diff --git a/src/main/java/edu/cmipt/gcs/pojo/repository/RepositoryPO.java b/src/main/java/edu/cmipt/gcs/pojo/repository/RepositoryPO.java new file mode 100644 index 0000000..74a7fc1 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/pojo/repository/RepositoryPO.java @@ -0,0 +1,94 @@ +package edu.cmipt.gcs.pojo.repository; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; + +import edu.cmipt.gcs.constant.GitConstant; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.nio.file.Paths; +import java.time.LocalDateTime; + +@Data +@AllArgsConstructor +@TableName("t_repository") +@NoArgsConstructor +public class RepositoryPO { + private Long id; + private String repositoryName; + private String repositoryDescription; + private Boolean isPrivate; + private Long userId; + private Integer star; + private Integer fork; + private Integer watcher; + private String httpsUrl; + private String sshUrl; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime gmtCreated; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime gmtUpdated; + + @TableLogic private LocalDateTime gmtDeleted; + + public RepositoryPO( + RepositoryDTO repositoryDTO, String userId, String username, boolean generateUrl) { + try { + this.id = Long.valueOf(repositoryDTO.id()); + } catch (NumberFormatException e) { + this.id = null; + } + this.repositoryName = repositoryDTO.repositoryName(); + this.repositoryDescription = repositoryDTO.repositoryDescription(); + if (this.repositoryDescription == null) { + this.repositoryDescription = ""; + } + this.isPrivate = repositoryDTO.isPrivate(); + if (this.isPrivate == null) { + this.isPrivate = false; + } + try { + this.userId = Long.valueOf(userId); + } catch (NumberFormatException e) { + this.userId = null; + } + if (generateUrl) { + this.generateUrl(username); + } + } + + public RepositoryPO(RepositoryDTO repositoryDTO, String userId, String username) { + this(repositoryDTO, userId, username, false); + } + + public RepositoryPO(RepositoryDTO repositoryDTO) { + this(repositoryDTO, null, null, false); + } + + public boolean generateUrl(String username) { + // TODO: https is not supported now + String httpsUrl = ""; + String sshUrl = + new StringBuilder("ssh://") + .append(GitConstant.GIT_SERVER_USERNAME) + .append("@") + .append(GitConstant.GIT_SERVER_DOMAIN) + .append(":") + .append(GitConstant.GIT_SERVER_PORT) + .append(Paths.get("/", username, this.repositoryName).toString()) + .toString(); + if (!httpsUrl.equals(this.httpsUrl) || !sshUrl.equals(this.sshUrl)) { + this.httpsUrl = httpsUrl; + this.sshUrl = sshUrl; + return true; + } + return false; + } +} diff --git a/src/main/java/edu/cmipt/gcs/pojo/repository/RepositoryVO.java b/src/main/java/edu/cmipt/gcs/pojo/repository/RepositoryVO.java new file mode 100644 index 0000000..c0fc17b --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/pojo/repository/RepositoryVO.java @@ -0,0 +1,33 @@ +package edu.cmipt.gcs.pojo.repository; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record RepositoryVO( + @Schema(description = "Repository ID") String id, + @Schema(description = "Repository Name") String repositoryName, + @Schema(description = "Repository Description") String repositoryDescription, + @Schema(description = "Whether or Not Private Repo") Boolean isPrivate, + @Schema(description = "Owner ID") String userId, + @Schema(description = "Owner name") String username, + @Schema(description = "Avatar url") String avatarUrl, + @Schema(description = "Star Count") Integer star, + @Schema(description = "Fork Count") Integer fork, + @Schema(description = "Watcher Count") Integer watcher, + @Schema(description = "HTTPS URL") String httpsUrl, + @Schema(description = "SSH URL") String sshUrl) { + public RepositoryVO(RepositoryPO repositoryPO, String username, String avatarUrl) { + this( + repositoryPO.getId().toString(), + repositoryPO.getRepositoryName(), + repositoryPO.getRepositoryDescription(), + repositoryPO.getIsPrivate(), + repositoryPO.getUserId().toString(), + username, + avatarUrl, + repositoryPO.getStar(), + repositoryPO.getFork(), + repositoryPO.getWatcher(), + repositoryPO.getHttpsUrl(), + repositoryPO.getSshUrl()); + } +} diff --git a/src/main/java/edu/cmipt/gcs/pojo/ssh/SshKeyDTO.java b/src/main/java/edu/cmipt/gcs/pojo/ssh/SshKeyDTO.java new file mode 100644 index 0000000..447877d --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/pojo/ssh/SshKeyDTO.java @@ -0,0 +1,33 @@ +package edu.cmipt.gcs.pojo.ssh; + +import edu.cmipt.gcs.constant.ValidationConstant; +import edu.cmipt.gcs.validation.group.CreateGroup; +import edu.cmipt.gcs.validation.group.UpdateGroup; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Null; +import jakarta.validation.constraints.Size; + +@Schema(description = "SSH Key Data Transfer Object") +public record SshKeyDTO( + @Schema(description = "SSH Key ID") + @Null(groups = CreateGroup.class) + @NotNull(groups = UpdateGroup.class) + String id, + @Schema(description = "Name", example = "My SSH Key") + @NotBlank(groups = {CreateGroup.class}) + @Size( + groups = {CreateGroup.class, UpdateGroup.class}, + min = ValidationConstant.MIN_SSH_KEY_NAME_LENGTH, + max = ValidationConstant.MAX_SSH_KEY_NAME_LENGTH) + String name, + @Schema(description = "Public Key") + @NotBlank(groups = CreateGroup.class) + @Size( + groups = {CreateGroup.class, UpdateGroup.class}, + min = ValidationConstant.MIN_SSH_KEY_PUBLIC_KEY_LENGTH, + max = ValidationConstant.MAX_SSH_KEY_PUBLIC_KEY_LENGTH) + String publicKey) {} diff --git a/src/main/java/edu/cmipt/gcs/pojo/ssh/SshKeyPO.java b/src/main/java/edu/cmipt/gcs/pojo/ssh/SshKeyPO.java new file mode 100644 index 0000000..8269185 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/pojo/ssh/SshKeyPO.java @@ -0,0 +1,50 @@ +package edu.cmipt.gcs.pojo.ssh; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@AllArgsConstructor +@TableName("t_ssh_key") +@NoArgsConstructor +public class SshKeyPO { + private Long id; + private Long userId; + private String name; + private String publicKey; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime gmtCreated; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime gmtUpdated; + + @TableLogic private LocalDateTime gmtDeleted; + + public SshKeyPO(SshKeyDTO sshKeyDTO, String userId) { + try { + this.id = Long.valueOf(sshKeyDTO.id()); + } catch (NumberFormatException e) { + this.id = null; + } + try { + this.userId = Long.valueOf(userId); + } catch (NumberFormatException e) { + this.userId = null; + } + this.name = sshKeyDTO.name(); + this.publicKey = sshKeyDTO.publicKey(); + } + + public SshKeyPO(SshKeyDTO sshKeyDTO) { + this(sshKeyDTO, null); + } +} diff --git a/src/main/java/edu/cmipt/gcs/pojo/ssh/SshKeyVO.java b/src/main/java/edu/cmipt/gcs/pojo/ssh/SshKeyVO.java new file mode 100644 index 0000000..b227645 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/pojo/ssh/SshKeyVO.java @@ -0,0 +1,14 @@ +package edu.cmipt.gcs.pojo.ssh; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "SSH Key Value Object") +public record SshKeyVO(String id, String userId, String name, String publicKey) { + public SshKeyVO(SshKeyPO sshKeyPO) { + this( + sshKeyPO.getId().toString(), + sshKeyPO.getUserId().toString(), + sshKeyPO.getName(), + sshKeyPO.getPublicKey()); + } +} diff --git a/src/main/java/edu/cmipt/gcs/pojo/user/UserCreateDTO.java b/src/main/java/edu/cmipt/gcs/pojo/user/UserCreateDTO.java new file mode 100644 index 0000000..fc81f69 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/pojo/user/UserCreateDTO.java @@ -0,0 +1,49 @@ +package edu.cmipt.gcs.pojo.user; + +import edu.cmipt.gcs.constant.ValidationConstant; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +@Schema(description = "User Sign Up Data Transfer Object") +public record UserCreateDTO( + @Schema( + description = "Username", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "admin") + @Size( + min = ValidationConstant.MIN_USERNAME_LENGTH, + max = ValidationConstant.MAX_USERNAME_LENGTH) + @NotBlank + @Pattern(regexp = ValidationConstant.USERNAME_PATTERN) + String username, + @Schema( + description = "Email", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "admin@cmipt.edu") + @Email + @NotBlank + String email, + @Schema( + description = "User Password (Unencrypted)", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "123456") + @Size( + min = ValidationConstant.MIN_PASSWORD_LENGTH, + max = ValidationConstant.MAX_PASSWORD_LENGTH) + @NotBlank + @Pattern(regexp = ValidationConstant.PASSWORD_PATTERN) + String userPassword, + @Schema( + description = "Email Verification Code", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "123456") + @Size( + min = ValidationConstant.EMAIL_VERIFICATION_CODE_LENGTH, + max = ValidationConstant.EMAIL_VERIFICATION_CODE_LENGTH) + @Pattern(regexp = ValidationConstant.EMAIL_VERIFICATION_CODE_PATTERN) + String emailVerificationCode) {} diff --git a/src/main/java/edu/cmipt/gcs/pojo/user/UserPO.java b/src/main/java/edu/cmipt/gcs/pojo/user/UserPO.java new file mode 100644 index 0000000..0a2edbe --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/pojo/user/UserPO.java @@ -0,0 +1,46 @@ +package edu.cmipt.gcs.pojo.user; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; + +import edu.cmipt.gcs.util.MD5Converter; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@AllArgsConstructor +@TableName("t_user") +@NoArgsConstructor +public class UserPO { + private Long id; + private String username; + private String email; + private String userPassword; + private String avatarUrl; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime gmtCreated; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime gmtUpdated; + + @TableLogic private LocalDateTime gmtDeleted; + + public UserPO(UserCreateDTO user) { + this.username = user.username(); + this.email = user.email(); + this.userPassword = MD5Converter.convertToMD5(user.userPassword()); + } + + public UserPO(UserUpdateDTO user) { + this.id = Long.parseLong(user.id()); + this.username = user.username(); + this.avatarUrl = user.avatarUrl(); + } +} diff --git a/src/main/java/edu/cmipt/gcs/pojo/user/UserSignInDTO.java b/src/main/java/edu/cmipt/gcs/pojo/user/UserSignInDTO.java new file mode 100644 index 0000000..8a94d31 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/pojo/user/UserSignInDTO.java @@ -0,0 +1,24 @@ +package edu.cmipt.gcs.pojo.user; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "User Sign In Data Transfer Object") +public record UserSignInDTO( + @Schema( + description = "Username", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "admin") + String username, + @Schema( + description = "User Email", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "user@example.com") + String email, + @Schema( + description = "User Password (Unencrypted)", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "123456") + @NotBlank + String userPassword) {} diff --git a/src/main/java/edu/cmipt/gcs/pojo/user/UserUpdateDTO.java b/src/main/java/edu/cmipt/gcs/pojo/user/UserUpdateDTO.java new file mode 100644 index 0000000..dc21361 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/pojo/user/UserUpdateDTO.java @@ -0,0 +1,37 @@ +package edu.cmipt.gcs.pojo.user; + +import edu.cmipt.gcs.constant.ValidationConstant; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * User Data Transfer Object + * + * @author Kaiser + */ +@Schema(description = "User Update Data Transfer Object") +public record UserUpdateDTO( + @Schema(description = "User ID") @NotNull + // The Long can not be expressed correctly in json, so use String instead + String id, + @Schema( + description = "Username", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "admin") + @Size( + min = ValidationConstant.MIN_USERNAME_LENGTH, + max = ValidationConstant.MAX_USERNAME_LENGTH) + @Pattern(regexp = ValidationConstant.USERNAME_PATTERN) + String username, + @Schema( + description = "Avatar URL", + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + example = "https://www.example.com/avatar.jpg") + @Size( + min = ValidationConstant.MIN_AVATAR_URL_LENGTH, + max = ValidationConstant.MAX_AVATAR_URL_LENGTH) + String avatarUrl) {} diff --git a/src/main/java/edu/cmipt/gcs/pojo/user/UserVO.java b/src/main/java/edu/cmipt/gcs/pojo/user/UserVO.java new file mode 100644 index 0000000..9e3c4c4 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/pojo/user/UserVO.java @@ -0,0 +1,20 @@ +package edu.cmipt.gcs.pojo.user; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "User Value Object") +public record UserVO( + // The Long can not be expressed correctly in json, so use String instead + @Schema(description = "User ID") String id, + @Schema(description = "Username", example = "admin") String username, + @Schema(description = "Email", example = "admin@cmipt.edu") String email, + @Schema(description = "Avatar URL", example = "https://www.example.com/avatar.jpg") + String avatarUrl) { + public UserVO(UserPO userPO) { + this( + userPO.getId().toString(), + userPO.getUsername(), + userPO.getEmail(), + userPO.getAvatarUrl()); + } +} diff --git a/src/main/java/edu/cmipt/gcs/service/RepositoryService.java b/src/main/java/edu/cmipt/gcs/service/RepositoryService.java new file mode 100644 index 0000000..71706a3 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/service/RepositoryService.java @@ -0,0 +1,7 @@ +package edu.cmipt.gcs.service; + +import com.baomidou.mybatisplus.extension.service.IService; + +import edu.cmipt.gcs.pojo.repository.RepositoryPO; + +public interface RepositoryService extends IService {} diff --git a/src/main/java/edu/cmipt/gcs/service/RepositoryServiceImpl.java b/src/main/java/edu/cmipt/gcs/service/RepositoryServiceImpl.java new file mode 100644 index 0000000..7d30c2b --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/service/RepositoryServiceImpl.java @@ -0,0 +1,70 @@ +package edu.cmipt.gcs.service; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; + +import edu.cmipt.gcs.dao.RepositoryMapper; +import edu.cmipt.gcs.enumeration.ErrorCodeEnum; +import edu.cmipt.gcs.exception.GenericException; +import edu.cmipt.gcs.pojo.repository.RepositoryPO; +import edu.cmipt.gcs.util.GitoliteUtil; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.Serializable; + +@Service +public class RepositoryServiceImpl extends ServiceImpl + implements RepositoryService { + private static final Logger logger = LoggerFactory.getLogger(RepositoryServiceImpl.class); + + @Autowired private UserService userService; + + /** + * Save a repository and initialize a git repository in the file system. + * + *

Usually, the user will not create the same repository at the same time, so we don't + * consider the thread competition + */ + @Transactional + @Override + public boolean save(RepositoryPO repositoryPO) { + if (!super.save(repositoryPO)) { + logger.error("Failed to save repository to database"); + return false; + } + if (!GitoliteUtil.createRepository( + repositoryPO.getId(), + repositoryPO.getRepositoryName(), + repositoryPO.getUserId(), + userService.getById(repositoryPO.getUserId()).getUsername(), + repositoryPO.getIsPrivate())) { + logger.error("Failed to create repository in gitolite"); + throw new GenericException(ErrorCodeEnum.REPOSITORY_CREATE_FAILED, repositoryPO); + } + return true; + } + + @Override + @Transactional + public boolean removeById(Serializable id) { + RepositoryPO repositoryPO = super.getById(id); + assert repositoryPO != null; + if (!super.removeById(id)) { + logger.error("Failed to remove repository from database"); + return false; + } + if (!GitoliteUtil.removeRepository( + repositoryPO.getRepositoryName(), + repositoryPO.getUserId(), + userService.getById(repositoryPO.getUserId()).getUsername(), + repositoryPO.getIsPrivate())) { + logger.error("Failed to remove repository from gitolite"); + throw new GenericException(ErrorCodeEnum.REPOSITORY_DELETE_FAILED, repositoryPO); + } + return true; + } +} diff --git a/src/main/java/edu/cmipt/gcs/service/SshKeyService.java b/src/main/java/edu/cmipt/gcs/service/SshKeyService.java new file mode 100644 index 0000000..ec1366f --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/service/SshKeyService.java @@ -0,0 +1,7 @@ +package edu.cmipt.gcs.service; + +import com.baomidou.mybatisplus.extension.service.IService; + +import edu.cmipt.gcs.pojo.ssh.SshKeyPO; + +public interface SshKeyService extends IService {} diff --git a/src/main/java/edu/cmipt/gcs/service/SshKeyServiceImpl.java b/src/main/java/edu/cmipt/gcs/service/SshKeyServiceImpl.java new file mode 100644 index 0000000..7779edf --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/service/SshKeyServiceImpl.java @@ -0,0 +1,70 @@ +package edu.cmipt.gcs.service; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; + +import edu.cmipt.gcs.dao.SshKeyMapper; +import edu.cmipt.gcs.enumeration.ErrorCodeEnum; +import edu.cmipt.gcs.exception.GenericException; +import edu.cmipt.gcs.pojo.ssh.SshKeyPO; +import edu.cmipt.gcs.util.GitoliteUtil; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.Serializable; + +@Service +public class SshKeyServiceImpl extends ServiceImpl + implements SshKeyService { + private static final Logger logger = LoggerFactory.getLogger(RepositoryServiceImpl.class); + + @Transactional + @Override + public boolean save(SshKeyPO sshKeyPO) { + if (!super.save(sshKeyPO)) { + logger.error("Failed to save SSH key to database"); + return false; + } + if (!GitoliteUtil.addSshKey( + sshKeyPO.getId(), sshKeyPO.getPublicKey(), sshKeyPO.getUserId())) { + logger.error("Failed to add SSH key to gitolite"); + throw new GenericException(ErrorCodeEnum.SSH_KEY_UPLOAD_FAILED, sshKeyPO); + } + return true; + } + + @Transactional + @Override + public boolean removeById(Serializable id) { + SshKeyPO sshKeyPO = super.getById(id); + assert sshKeyPO != null; + if (!super.removeById(id)) { + logger.error("Failed to remove SSH key from database"); + return false; + } + if (!GitoliteUtil.removeSshKey(sshKeyPO.getId(), sshKeyPO.getUserId())) { + logger.error("Failed to remove SSH key from gitolite"); + throw new GenericException(ErrorCodeEnum.SSH_KEY_DELETE_FAILED, sshKeyPO); + } + return true; + } + + @Transactional + @Override + public boolean updateById(SshKeyPO sshKeyPO) { + String originSshKey = super.getById(sshKeyPO.getId()).getPublicKey(); + assert originSshKey != null; + if (!super.updateById(sshKeyPO)) { + logger.error("Failed to update SSH key in database"); + return false; + } + // no need to update file, we just return true + if (sshKeyPO.getPublicKey() == null || originSshKey.equals(sshKeyPO.getPublicKey())) { + return true; + } + GitoliteUtil.updateSshKey(sshKeyPO.getId(), sshKeyPO.getPublicKey()); + return true; + } +} diff --git a/src/main/java/edu/cmipt/gcs/service/UserCollaborateRepositoryService.java b/src/main/java/edu/cmipt/gcs/service/UserCollaborateRepositoryService.java new file mode 100644 index 0000000..171673f --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/service/UserCollaborateRepositoryService.java @@ -0,0 +1,12 @@ +package edu.cmipt.gcs.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; + +import edu.cmipt.gcs.pojo.collaboration.UserCollaborateRepositoryPO; +import edu.cmipt.gcs.pojo.user.UserPO; + +public interface UserCollaborateRepositoryService extends IService { + IPage pageCollaboratorsByRepositoryId(Long repositoryId, Page page); +} diff --git a/src/main/java/edu/cmipt/gcs/service/UserCollaborateRepositoryServiceImpl.java b/src/main/java/edu/cmipt/gcs/service/UserCollaborateRepositoryServiceImpl.java new file mode 100644 index 0000000..3eb8121 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/service/UserCollaborateRepositoryServiceImpl.java @@ -0,0 +1,85 @@ +package edu.cmipt.gcs.service; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; + +import edu.cmipt.gcs.dao.UserCollaborateRepositoryMapper; +import edu.cmipt.gcs.enumeration.ErrorCodeEnum; +import edu.cmipt.gcs.exception.GenericException; +import edu.cmipt.gcs.pojo.collaboration.UserCollaborateRepositoryPO; +import edu.cmipt.gcs.pojo.user.UserPO; +import edu.cmipt.gcs.util.GitoliteUtil; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.Serializable; +import java.util.List; + +@Service +public class UserCollaborateRepositoryServiceImpl + extends ServiceImpl + implements UserCollaborateRepositoryService { + private static final Logger logger = + LoggerFactory.getLogger(UserCollaborateRepositoryServiceImpl.class); + + @Autowired RepositoryService repositoryService; + @Autowired UserService userService; + + @Override + @Transactional + public boolean save(UserCollaborateRepositoryPO userCollaborateRepository) { + if (!super.save(userCollaborateRepository)) { + logger.error("Failed to save user collaborate repository to database"); + return false; + } + Long repositoryId = userCollaborateRepository.getRepositoryId(); + Long collaboratorId = userCollaborateRepository.getCollaboratorId(); + Long repositoryUserId = repositoryService.getById(repositoryId).getUserId(); + if (!GitoliteUtil.addCollaborator(repositoryUserId, repositoryId, collaboratorId)) { + logger.error("Failed to add collaborator to gitolite"); + throw new GenericException( + ErrorCodeEnum.COLLABORATION_ADD_FAILED, collaboratorId, repositoryId); + } + return true; + } + + @Override + @Transactional + public boolean removeById(Serializable id) { + var userCollaborateRepository = super.getById(id); + Long repositoryId = userCollaborateRepository.getRepositoryId(); + Long collaboratorId = userCollaborateRepository.getCollaboratorId(); + Long repositoryUserId = repositoryService.getById(repositoryId).getUserId(); + if (!super.removeById(id)) { + logger.error("Failed to remove user collaborate repository from database"); + return false; + } + if (!GitoliteUtil.removeCollaborator(repositoryUserId, repositoryId, collaboratorId)) { + logger.error("Failed to remove collaborator from gitolite"); + throw new GenericException( + ErrorCodeEnum.COLLABORATION_REMOVE_FAILED, collaboratorId, repositoryId); + } + return true; + } + + @Override + public IPage pageCollaboratorsByRepositoryId(Long repositoryId, Page page) { + QueryWrapper queryWrapper = new QueryWrapper<>(); + List collaboratorIds = + super.listObjs( + new QueryWrapper() + .eq("repository_id", repositoryId) + .select("collaborator_id")); + if (collaboratorIds == null || collaboratorIds.isEmpty()) { + return new Page<>(); + } + queryWrapper.in("id", collaboratorIds); + return userService.page(page, queryWrapper); + } +} diff --git a/src/main/java/edu/cmipt/gcs/service/UserService.java b/src/main/java/edu/cmipt/gcs/service/UserService.java new file mode 100644 index 0000000..d74a743 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/service/UserService.java @@ -0,0 +1,11 @@ +package edu.cmipt.gcs.service; + +import com.baomidou.mybatisplus.extension.service.IService; + +import edu.cmipt.gcs.pojo.user.UserPO; + +public interface UserService extends IService { + boolean usernameExists(String username); + + boolean emailExists(String email); +} diff --git a/src/main/java/edu/cmipt/gcs/service/UserServiceImpl.java b/src/main/java/edu/cmipt/gcs/service/UserServiceImpl.java new file mode 100644 index 0000000..336e4ac --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/service/UserServiceImpl.java @@ -0,0 +1,72 @@ +package edu.cmipt.gcs.service; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; + +import edu.cmipt.gcs.dao.UserMapper; +import edu.cmipt.gcs.enumeration.ErrorCodeEnum; +import edu.cmipt.gcs.exception.GenericException; +import edu.cmipt.gcs.pojo.ssh.SshKeyPO; +import edu.cmipt.gcs.pojo.user.UserPO; +import edu.cmipt.gcs.util.GitoliteUtil; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.Serializable; + +@Service +public class UserServiceImpl extends ServiceImpl implements UserService { + private static final Logger logger = LoggerFactory.getLogger(UserServiceImpl.class); + + @Autowired SshKeyService sshKeyService; + + @Override + @Transactional + public boolean removeById(Serializable id) { + if (!super.removeById(id)) { + logger.error("Failed to remove user from database"); + return false; + } + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("user_id", id); + var sshKeyList = sshKeyService.list(wrapper); + for (var sshKey : sshKeyList) { + if (!sshKeyService.removeById(sshKey.getId())) { + throw new GenericException(ErrorCodeEnum.USER_DELETE_FAILED, sshKey); + } + } + return true; + } + + @Override + @Transactional + public boolean save(UserPO user) { + if (!super.save(user)) { + logger.error("Failed to save user to database"); + return false; + } + if (!GitoliteUtil.initUserConfig(user.getId())) { + logger.error("Failed to add user to gitolite"); + throw new GenericException(ErrorCodeEnum.USER_CREATE_FAILED, user); + } + return true; + } + + @Override + public boolean usernameExists(String username) { + QueryWrapper wrapper = new QueryWrapper(); + wrapper.apply("LOWER(username) = LOWER({0})", username); + return super.exists(wrapper); + } + + @Override + public boolean emailExists(String email) { + QueryWrapper wrapper = new QueryWrapper(); + wrapper.apply("LOWER(email) = LOWER({0})", email); + return super.exists(wrapper); + } +} diff --git a/src/main/java/edu/cmipt/gcs/util/EmailVerificationCodeUtil.java b/src/main/java/edu/cmipt/gcs/util/EmailVerificationCodeUtil.java new file mode 100644 index 0000000..c53323a --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/util/EmailVerificationCodeUtil.java @@ -0,0 +1,35 @@ +package edu.cmipt.gcs.util; + +import edu.cmipt.gcs.constant.ApplicationConstant; +import edu.cmipt.gcs.constant.ValidationConstant; + +public class EmailVerificationCodeUtil { + public static String generateVerificationCode(String email) { + String code = + String.valueOf( + (int) + ((Math.random() * 9 + 1) + * Math.pow( + 10, + ValidationConstant.EMAIL_VERIFICATION_CODE_LENGTH + - 1))); + RedisUtil.set( + generateRedisKey(email), + code, + ApplicationConstant.EMAIL_VERIFICATION_CODE_EXPIRATION); + return code; + } + + public static boolean verifyVerificationCode(String email, String verificationCode) { + if (verificationCode == null + || !verificationCode.equals(RedisUtil.get(generateRedisKey(email)))) { + return false; + } + RedisUtil.del(generateRedisKey(email)); + return true; + } + + private static String generateRedisKey(String email) { + return "email#" + email; + } +} diff --git a/src/main/java/edu/cmipt/gcs/util/GitoliteUtil.java b/src/main/java/edu/cmipt/gcs/util/GitoliteUtil.java new file mode 100644 index 0000000..ba2f0c1 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/util/GitoliteUtil.java @@ -0,0 +1,426 @@ +package edu.cmipt.gcs.util; + +import edu.cmipt.gcs.constant.GitConstant; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.LinkedList; +import java.util.List; + +public class GitoliteUtil { + private static final Logger logger = LoggerFactory.getLogger(GitoliteUtil.class); + + public static synchronized boolean initUserConfig(Long userId) { + var userFileName = new StringBuilder().append(userId).append(".conf").toString(); + var userConfPath = Paths.get(GitConstant.GITOLITE_USER_CONF_DIR_PATH, userFileName); + if (Files.exists(userConfPath)) { + logger.error("Duplicate user file"); + return false; + } + try { + Files.createFile(userConfPath); + String content = + """ + @%d_public_repo = testing + @%d_private_repo = testing + @%d_ssh_key = @admin + repo @%d_private_repo + RW+ = @%d_ssh_key + repo @%d_public_repo + RW+ = @%d_ssh_key + """ + .formatted(userId, userId, userId, userId, userId, userId, userId); + Files.writeString(userConfPath, content); + List lines = Files.readAllLines(Paths.get(GitConstant.GITOLITE_CONF_FILE_PATH)); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + if (line.startsWith("@all_public_repo")) { + lines.set(i, line + ' ' + "@%d_public_repo".formatted(userId)); + Files.write(Paths.get(GitConstant.GITOLITE_CONF_FILE_PATH), lines); + String message = "Add user " + userId; + Path[] files = { + Paths.get("conf", "gitolite.d", "user", userFileName), + Paths.get("conf", "gitolite.conf") + }; + if (!GitoliteUtil.commitAndPush(message, files)) { + logger.error("Failed to commit and push changes"); + return false; + } + return true; + } + } + } catch (Exception e) { + logger.error(e.getMessage()); + return false; + } + logger.error("Can not find @all_public_repo in gitolite.conf"); + return false; + } + + public static synchronized boolean addSshKey(Long sshKeyId, String key, Long userId) { + var sshKeyFileName = new StringBuilder().append(sshKeyId).append(".pub").toString(); + var sshKeyPath = Paths.get(GitConstant.GITOLITE_KEY_DIR_PATH, sshKeyFileName); + if (Files.exists(sshKeyPath)) { + logger.error("Duplicate SSH file"); + return false; + } + try { + var userFileName = new StringBuilder().append(userId).append(".conf").toString(); + Files.writeString(sshKeyPath, key); + List lines = + Files.readAllLines( + Paths.get(GitConstant.GITOLITE_USER_CONF_DIR_PATH, userFileName)); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + if (line.startsWith("@%d_ssh_key".formatted(userId))) { + lines.set(i, line + ' ' + sshKeyId); + Files.write( + Paths.get(GitConstant.GITOLITE_USER_CONF_DIR_PATH, userFileName), + lines); + String message = "Add ssh key " + sshKeyId; + Path[] files = { + Paths.get("keydir", sshKeyFileName), + Paths.get("conf", "gitolite.d", "user", userFileName) + }; + if (!GitoliteUtil.commitAndPush(message, files)) { + logger.error("Failed to commit and push"); + return false; + } + return true; + } + } + } catch (Exception e) { + logger.error(e.getMessage()); + return false; + } + logger.error("Can not find @{}_ssh_key in user configuration".formatted(userId)); + return false; + } + + public static synchronized boolean removeSshKey(Long sshKeyId, Long userId) { + var sshKeyFileName = new StringBuilder().append(sshKeyId).append(".pub").toString(); + var sshKeyPath = Paths.get(GitConstant.GITOLITE_KEY_DIR_PATH, sshKeyFileName); + if (!Files.exists(sshKeyPath)) { + logger.warn("Trying to remove a non-existent SSH key file: {}", sshKeyPath); + return true; + } + try { + Files.delete(sshKeyPath); + } catch (Exception e) { + logger.error("Failed to delete SSH key file: {}", e.getMessage()); + return false; + } + try { + var userFileName = new StringBuilder().append(userId).append(".conf").toString(); + List lines = + Files.readAllLines( + Paths.get(GitConstant.GITOLITE_USER_CONF_DIR_PATH, userFileName)); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + if (line.startsWith("@%d_ssh_key".formatted(userId))) { + lines.set(i, line.replace(" " + sshKeyId, "")); + Files.write( + Paths.get(GitConstant.GITOLITE_USER_CONF_DIR_PATH, userFileName), + lines); + String message = "Remove ssh key " + sshKeyId; + Path[] files = { + Paths.get("keydir", sshKeyFileName), + Paths.get("conf", "gitolite.d", "user", userFileName) + }; + if (!GitoliteUtil.commitAndPush(message, files)) { + logger.error("Failed to commit and push"); + return false; + } + return true; + } + } + } catch (Exception e) { + logger.error(e.getMessage()); + return false; + } + logger.error("Can not find @{}_ssh_key in user configuration".formatted(userId)); + return false; + } + + public static synchronized boolean updateSshKey(Long sshKeyId, String key) { + var sshKeyFileName = new StringBuilder().append(sshKeyId).append(".pub").toString(); + var sshKeyPath = Paths.get(GitConstant.GITOLITE_KEY_DIR_PATH, sshKeyFileName); + if (!Files.exists(sshKeyPath)) { + logger.error("Trying to update a non-existent SSH key file: {}", sshKeyPath); + return false; + } + try { + Files.writeString(sshKeyPath, key); + String message = "Update ssh key " + sshKeyId; + Path[] files = {Paths.get("keydir", sshKeyFileName)}; + if (!GitoliteUtil.commitAndPush(message, files)) { + logger.error("Failed to commit and push"); + return false; + } + } catch (Exception e) { + logger.error(e.getMessage()); + return false; + } + return true; + } + + public static synchronized boolean createRepository( + Long repositoryId, + String repositoryName, + Long userId, + String userName, + boolean isPrivate) { + var userFileName = new StringBuilder().append(userId).append(".conf").toString(); + var repositoryFileName = + new StringBuilder().append(repositoryId).append(".conf").toString(); + var repositoryConfPath = + Paths.get(GitConstant.GITOLITE_REPOSITORY_CONF_DIR_PATH, repositoryFileName); + if (Files.exists(repositoryConfPath)) { + logger.error("Duplicate repository file"); + return false; + } + try { + Files.createFile(repositoryConfPath); + String content = + """ + @%d_repo_collaborator = @admin + repo %s/%s + RW+ = @%d_repo_collaborator + """ + .formatted(repositoryId, userName, repositoryName, repositoryId); + Files.writeString(repositoryConfPath, content); + List lines = + Files.readAllLines( + Paths.get(GitConstant.GITOLITE_USER_CONF_DIR_PATH, userFileName)); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + if (line.startsWith( + "@%d_%s_repo".formatted(userId, isPrivate ? "private" : "public"))) { + lines.set(i, line + ' ' + userName + '/' + repositoryName); + Files.write( + Paths.get(GitConstant.GITOLITE_USER_CONF_DIR_PATH, userFileName), + lines); + String message = "Add repository " + userName + '/' + repositoryName; + Path[] files = { + Paths.get("conf", "gitolite.d", "user", userFileName), + Paths.get("conf", "gitolite.d", "repository", repositoryFileName) + }; + if (!GitoliteUtil.commitAndPush(message, files)) { + logger.error("Failed to commit and push"); + return false; + } + return true; + } + } + } catch (Exception e) { + logger.error(e.getMessage()); + return false; + } + logger.error( + "Can not find @{}_{}_repo in user configuration" + .formatted(userId, isPrivate ? "private" : "public")); + return false; + } + + public static synchronized boolean removeRepository( + String repositoryName, Long userId, String userName, boolean isPrivate) { + var userFileName = new StringBuilder().append(userId).append(".conf").toString(); + try { + List lines = + Files.readAllLines( + Paths.get(GitConstant.GITOLITE_USER_CONF_DIR_PATH, userFileName)); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + if (line.startsWith( + "@%d_%s_repo".formatted(userId, isPrivate ? "private" : "public"))) { + lines.set(i, line.replace(" " + userName + '/' + repositoryName, "")); + Files.write( + Paths.get(GitConstant.GITOLITE_USER_CONF_DIR_PATH, userFileName), + lines); + String message = "Remove repository " + userName + '/' + repositoryName; + Path[] files = {Paths.get("conf", "gitolite.d", "repository", userFileName)}; + if (!GitoliteUtil.commitAndPush(message, files)) { + logger.error("Failed to commit and push"); + } + String repositorySavePath = + Paths.get( + GitConstant.GIT_SERVER_HOME, + "repositories", + userName, + repositoryName + ".git") + .toString(); + ProcessBuilder dirRemover = + new ProcessBuilder( + "sudo", + "-u", + GitConstant.GIT_SERVER_USERNAME, + "rm", + "-rf", + repositorySavePath); + Process process = dirRemover.start(); + if (process.waitFor() != 0) { + logger.error("Failed to remove repository directory"); + return false; + } + return true; + } + } + } catch (Exception e) { + logger.error(e.getMessage()); + return false; + } + logger.error( + "Can not find @{}_{}_repo in user configuration" + .formatted(userId, isPrivate ? "private" : "public")); + return false; + } + + public static synchronized boolean addCollaborator( + Long repositoryOwnerId, Long repositoryId, Long collaboratorId) { + var repositoryFileName = + new StringBuilder().append(repositoryId).append(".conf").toString(); + var repositoryConfPath = + Paths.get(GitConstant.GITOLITE_REPOSITORY_CONF_DIR_PATH, repositoryFileName); + if (!Files.exists(repositoryConfPath)) { + logger.error("Repository file does not exist"); + return false; + } + try { + List lines = Files.readAllLines(repositoryConfPath); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + if (line.startsWith("@%d_repo_collaborator".formatted(repositoryId))) { + lines.set(i, line + " @%d_ssh_key".formatted(collaboratorId)); + Files.write(repositoryConfPath, lines); + String message = + "Add collaborator " + collaboratorId + " to repository " + repositoryId; + Path[] files = { + Paths.get("conf", "gitolite.d", "repository", repositoryFileName) + }; + if (!GitoliteUtil.commitAndPush(message, files)) { + logger.error("Failed to commit and push"); + return false; + } + return true; + } + } + } catch (Exception e) { + logger.error(e.getMessage()); + return false; + } + logger.error( + "Can not find @{}_repo_collaborator in repository configuration" + .formatted(repositoryId)); + return false; + } + + public static synchronized boolean removeCollaborator( + Long repositoryOwnerId, Long repositoryId, Long collaboratorId) { + var repositoryFileName = + new StringBuilder().append(repositoryId).append(".conf").toString(); + var repositoryConfPath = + Paths.get(GitConstant.GITOLITE_REPOSITORY_CONF_DIR_PATH, repositoryFileName); + if (!Files.exists(repositoryConfPath)) { + logger.error("Repository file does not exist"); + return false; + } + try { + List lines = Files.readAllLines(repositoryConfPath); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + if (line.startsWith("@%d_repo_collaborator".formatted(repositoryId))) { + lines.set(i, line.replace(" @%d_ssh_key".formatted(collaboratorId), "")); + Files.write(repositoryConfPath, lines); + String message = + "Remove collaborator " + + collaboratorId + + " from repository " + + repositoryId; + Path[] files = { + Paths.get("conf", "gitolite.d", "repository", repositoryFileName) + }; + if (!GitoliteUtil.commitAndPush(message, files)) { + logger.error("Failed to commit and push"); + return false; + } + return true; + } + } + } catch (Exception e) { + logger.error(e.getMessage()); + return false; + } + logger.error( + "Can not find @{}_repo_collaborator in repository configuration" + .formatted(repositoryId)); + return false; + } + + private static synchronized boolean commitAndPush(String message, Path... files) { + if (files.length == 0) { + logger.error("No files to commit"); + return false; + } + try { + List command = new LinkedList<>(); + command.add("git"); + command.add("-C"); + command.add(GitConstant.GIT_SERVER_ADMIN_REPOSITORY); + command.add("add"); + command.addAll(List.of(files).stream().map(Path::toString).toList()); + ProcessBuilder add = new ProcessBuilder(command); + Process process = add.start(); + if (process.waitFor() != 0) { + logger.error("Failed to add files: {}", List.of(files)); + throw new RuntimeException(process.errorReader().lines().toList().toString()); + } + ProcessBuilder commit = + new ProcessBuilder( + "git", + "-C", + GitConstant.GIT_SERVER_ADMIN_REPOSITORY, + "commit", + "-m", + message); + process = commit.start(); + if (process.waitFor() != 0) { + logger.error("Failed to commit changes"); + throw new RuntimeException(process.errorReader().lines().toList().toString()); + } + ProcessBuilder push = + new ProcessBuilder( + "git", "-C", GitConstant.GIT_SERVER_ADMIN_REPOSITORY, "push"); + process = push.start(); + if (process.waitFor() != 0) { + logger.error("Failed to push changes"); + throw new RuntimeException(process.errorReader().lines().toList().toString()); + } + } catch (Exception e) { + // reset the state of the repository + try { + ProcessBuilder reset = + new ProcessBuilder( + "git", + "-C", + GitConstant.GIT_SERVER_ADMIN_REPOSITORY, + "reset", + "--hard", + "HEAD^"); + Process process = reset.start(); + if (process.waitFor() != 0) { + logger.error("Failed to reset repository"); + throw new RuntimeException(process.errorReader().lines().toList().toString()); + } + } catch (Exception ex) { + logger.error(ex.getMessage()); + } + logger.error(e.getMessage()); + return false; + } + return true; + } +} diff --git a/src/main/java/edu/cmipt/gcs/util/JwtUtil.java b/src/main/java/edu/cmipt/gcs/util/JwtUtil.java new file mode 100644 index 0000000..e8d70e6 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/util/JwtUtil.java @@ -0,0 +1,145 @@ +package edu.cmipt.gcs.util; + +import edu.cmipt.gcs.constant.ApplicationConstant; +import edu.cmipt.gcs.constant.HeaderParameter; +import edu.cmipt.gcs.enumeration.ErrorCodeEnum; +import edu.cmipt.gcs.enumeration.TokenTypeEnum; +import edu.cmipt.gcs.exception.GenericException; + +import io.jsonwebtoken.Jwts; + +import org.springframework.http.HttpHeaders; + +import java.util.Date; + +import javax.crypto.SecretKey; + +/** + * JwtUtil + * + * @author Kaiser + */ +public class JwtUtil { + private static final String TOKEN_TYPE_CLAIM = "tokenType"; + private static final String ID_CLAIM = "id"; + private static final SecretKey SECRET_KEY = Jwts.SIG.HS256.key().build(); + + /** + * Generate a token + * + * @param id The id of the user + * @param tokenType The type of the token + * @return The generated access token + */ + public static String generateToken(long id, TokenTypeEnum tokenType) { + String token = + Jwts.builder() + .issuedAt(new Date()) + .claim(ID_CLAIM, id) + .claim(TOKEN_TYPE_CLAIM, tokenType.name()) + .signWith(SECRET_KEY) + .compact(); + setTokenInRedis(token, tokenType); + return token; + } + + public static String generateToken(String id, TokenTypeEnum tokenType) { + return generateToken(Long.valueOf(id), tokenType); + } + + public static String getId(String token) { + if (!RedisUtil.hasKey(generateRedisKey(token))) { + throw new GenericException(ErrorCodeEnum.INVALID_TOKEN, token); + } + try { + return String.valueOf( + Jwts.parser() + .verifyWith(SECRET_KEY) + .build() + .parseSignedClaims(token) + .getPayload() + .get(ID_CLAIM, Long.class)); + } catch (Exception e) { + throw new GenericException(ErrorCodeEnum.INVALID_TOKEN, token); + } + } + + public static TokenTypeEnum getTokenType(String token) { + if (!RedisUtil.hasKey(generateRedisKey(token))) { + throw new GenericException(ErrorCodeEnum.INVALID_TOKEN, token); + } + try { + return TokenTypeEnum.valueOf( + Jwts.parser() + .verifyWith(SECRET_KEY) + .build() + .parseSignedClaims(token) + .getPayload() + .get(TOKEN_TYPE_CLAIM, String.class)); + } catch (Exception e) { + throw new GenericException(ErrorCodeEnum.INVALID_TOKEN, token); + } + } + + public static HttpHeaders generateHeaders(String id) { + return generateHeaders(id, true); + } + + public static HttpHeaders generateHeaders(String id, boolean addRefreshToken) { + HttpHeaders headers = new HttpHeaders(); + headers.add(HeaderParameter.ACCESS_TOKEN, generateToken(id, TokenTypeEnum.ACCESS_TOKEN)); + if (addRefreshToken) { + headers.add( + HeaderParameter.REFRESH_TOKEN, generateToken(id, TokenTypeEnum.REFRESH_TOKEN)); + } + return headers; + } + + /** + * Add tokens of a user to blacklist + * + * @author Kaiser + * @param tokens + */ + public static void blacklistToken(Long id) { + RedisUtil.del(generateRedisKey(id, TokenTypeEnum.ACCESS_TOKEN)); + RedisUtil.del(generateRedisKey(id, TokenTypeEnum.REFRESH_TOKEN)); + } + + public static void blacklistToken(String id) { + blacklistToken(Long.valueOf(id)); + } + + private static String generateRedisKey(Long id, TokenTypeEnum tokenType) { + return "id:tokenType#" + id + ":" + tokenType.name(); + } + + public static void refreshToken(String token) { + setTokenInRedis(token, getTokenType(token)); + } + + private static void setTokenInRedis(String token, TokenTypeEnum tokenType) { + RedisUtil.set( + generateRedisKey(token), + token, + (tokenType == TokenTypeEnum.ACCESS_TOKEN + ? ApplicationConstant.ACCESS_TOKEN_EXPIRATION + : ApplicationConstant.REFRESH_TOKEN_EXPIRATION)); + } + + private static String generateRedisKey(String token) { + try { + var payload = + Jwts.parser() + .verifyWith(SECRET_KEY) + .build() + .parseSignedClaims(token) + .getPayload(); + Long id = payload.get(ID_CLAIM, Long.class); + String tokenType = payload.get(TOKEN_TYPE_CLAIM, String.class); + return generateRedisKey(id, TokenTypeEnum.valueOf(tokenType)); + } catch (Exception e) { + throw new GenericException(ErrorCodeEnum.INVALID_TOKEN, token); + } + } +} diff --git a/src/main/java/edu/cmipt/gcs/util/MD5Converter.java b/src/main/java/edu/cmipt/gcs/util/MD5Converter.java new file mode 100644 index 0000000..8f2a2f3 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/util/MD5Converter.java @@ -0,0 +1,43 @@ +package edu.cmipt.gcs.util; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +@Component +public class MD5Converter { + private static String MD5_SALT; + + @Value("${md5.salt}") + public void setMD5Salt(String salt) { + MD5_SALT = salt; + if (MD5_SALT == null) { + MD5_SALT = ""; + } + } + + public static String convertToMD5(String input) { + try { + byte[] hashBytes = + MessageDigest.getInstance("MD5") + .digest( + new StringBuilder(input) + .append(MD5_SALT) + .toString() + .getBytes()); + StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/edu/cmipt/gcs/util/MessageSourceUtil.java b/src/main/java/edu/cmipt/gcs/util/MessageSourceUtil.java new file mode 100644 index 0000000..d350050 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/util/MessageSourceUtil.java @@ -0,0 +1,33 @@ +package edu.cmipt.gcs.util; + +import edu.cmipt.gcs.enumeration.ErrorCodeEnum; + +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.stereotype.Component; + +@Component +public class MessageSourceUtil { + private static MessageSource messageSource; + + MessageSourceUtil(MessageSource messageSource) { + MessageSourceUtil.messageSource = messageSource; + } + + public static String getMessage(ErrorCodeEnum code, Object... args) { + return getMessage(code.getCode(), args); + } + + public static String getMessage(String code, Object... args) { + try { + return messageSource.getMessage(code, args, LocaleContextHolder.getLocale()); + } catch (Exception e) { + // ignore + } + try { + return messageSource.getMessage(code, null, LocaleContextHolder.getLocale()); + } catch (Exception e) { + return ""; + } + } +} diff --git a/src/main/java/edu/cmipt/gcs/util/RedisUtil.java b/src/main/java/edu/cmipt/gcs/util/RedisUtil.java new file mode 100644 index 0000000..586c8ee --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/util/RedisUtil.java @@ -0,0 +1,38 @@ +package edu.cmipt.gcs.util; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Component +public class RedisUtil { + private static RedisTemplate redisTemplate; + + public RedisUtil(RedisTemplate redisTemplate) { + RedisUtil.redisTemplate = redisTemplate; + } + + public static Object get(String key) { + return redisTemplate.opsForValue().get(key); + } + + /** + * @param key key + * @param value object to be stored + * @param expireTime time to live in milliseconds + */ + public static void set(String key, Object value, Long expireTime) { + redisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.MILLISECONDS); + } + + public static void del(String... keys) { + for (String key : keys) { + redisTemplate.delete(key); + } + } + + public static boolean hasKey(String key) { + return redisTemplate.hasKey(key); + } +} diff --git a/src/main/java/edu/cmipt/gcs/validation/group/CreateGroup.java b/src/main/java/edu/cmipt/gcs/validation/group/CreateGroup.java new file mode 100644 index 0000000..4fdd109 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/validation/group/CreateGroup.java @@ -0,0 +1,3 @@ +package edu.cmipt.gcs.validation.group; + +public interface CreateGroup {} diff --git a/src/main/java/edu/cmipt/gcs/validation/group/UpdateGroup.java b/src/main/java/edu/cmipt/gcs/validation/group/UpdateGroup.java new file mode 100644 index 0000000..e9bbf50 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/validation/group/UpdateGroup.java @@ -0,0 +1,3 @@ +package edu.cmipt.gcs.validation.group; + +public interface UpdateGroup {} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..5dbe643 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,9 @@ +spring: + datasource: + druid: + test-on-borrow: true + test-on-return: true +logging: + level: + # DEBUG, ERROR, FATAL, INFO, OFF, TRACE, WARN + gcs: "DEBUG" diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..8a8a720 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,5 @@ +spring: + datasource: + druid: + test-on-borrow: false + test-on-return: false diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 0000000..2ea84be --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,5 @@ +spring: + datasource: + druid: + test-on-borrow: true + test-on-return: true diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..d739231 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,23 @@ +spring.profiles.active=${SPRING_PROFILES_ACTIVE} +spring.datasource.druid.url=${SPRING_DATASOURCE_URL} +spring.datasource.druid.username=${SPRING_DATASOURCE_USERNAME} +spring.datasource.druid.password=${SPRING_DATASOURCE_PASSWORD} +spring.datasource.druid.stat-view-servlet.login-username=${SPRING_DRUID_USERNAME} +spring.datasource.druid.stat-view-servlet.login-password=${SPRING_DRUID_PASSWORD} +spring.redis.host=${SPRING_REDIS_HOST} +spring.redis.port=${SPRING_REDIS_PORT} +spring.mail.host=${SPRING_MAIL_HOST} +spring.mail.port=${SPRING_MAIL_PORT} +spring.mail.username=${SPRING_MAIL_USERNAME} +spring.mail.password=${SPRING_MAIL_PASSWORD} +spring.mail.protocol=${SPRING_MAIL_PROTOCOL} +spring.mail.default-encoding=${SPRING_MAIL_DEFAULT_ENCODING} +logging.group.gcs=edu.cmipt.gcs + +git.server.domain=${GIT_SERVER_DOMAIN} +git.server.port=${GIT_SERVER_PORT} +git.server.username=${GIT_SERVER_USERNAME} +git.server.home=${GIT_SERVER_HOME} +git.server.admin.repository=${GIT_SERVER_ADMIN_REPOSITORY} +front-end.url=${FRONT_END_URL} +md5.salt=${MD5_SALT} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..2e800ab --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,61 @@ +spring: + application: + name: gcs + datasource: + druid: + type: com.alibaba.druid.pool.DruidDataSource + driver-class-name: org.postgresql.Driver + initial-size: 5 + min-idle: 5 + max-active: 20 + max-wait: 6000 # unit: ms + time-between-eviction-runs-millis: 60000 + min-evication-idle-time-millis: 600000 # min alive time of a connection + max-evication-idle-time-millis: 1200000 # max alive time of a connection + validation-query: SELECT 1 + test-while-idle: true + async-init: true + keep-alive: true + filters: + stat: + enable: true + log-slow-sql: true + slow-sql-millis: 1000 + wall: + enable: true + log-violation: true + throw-exception: false + config: + drop-table-allow: false + delete-allow: false + web-stat-filter: + enabled: true + url-pattern: /* + exclusions: "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*" + session-stat-enable: true + session-stat-max-count: 1000 + stat-view-servlet: + enabled: true + url-pattern: /druid/* + reset-enable: false + allow: # empty means allow all + messages: + basename: message/exception,message/validation,message/message + encoding: UTF-8 + +mybatis-plus: + global-config: + db-config: + logic-delete-field: gmt_deleted + logic-delete-value: now() + logic-not-delete-value: "null" + +logging: + include-application-name: false + +# force to add encoding UTF-8 to response header +server: + servlet: + encoding: + charset: UTF-8 + force: true diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..4df0871 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,32 @@ + + + + + + + + + ${LOG_PATTERN} + + + + + + ${GCS_LOGGING_DIRECTORY}/gcs.log + + ${GCS_LOGGING_DIRECTORY}/gcs-%d{yyyy-MM-dd}.%i.log + ${GCS_LOGGING_FILE_MAX_SIZE} + ${GCS_LOGGING_MAX_HISTORY} + ${GCS_LOGGING_TOTAL_SIZE_CAP} + + + ${LOG_PATTERN} + + + + + + + + + diff --git a/src/main/resources/message/exception.properties b/src/main/resources/message/exception.properties new file mode 100644 index 0000000..02b1bb9 --- /dev/null +++ b/src/main/resources/message/exception.properties @@ -0,0 +1,43 @@ +USERNAME_RESERVED=Username is reserved: {0} +USERNAME_ALREADY_EXISTS=Username already exists: {0} +EMAIL_ALREADY_EXISTS=Email already exists: {0} +WRONG_SIGN_IN_INFORMATION=Wrong sign in information + +INVALID_TOKEN=Invalid token: {0} +TOKEN_NOT_FOUND=Token not found in header +ACCESS_DENIED=Operation without privileges + +MESSAGE_CONVERSION_ERROR=Error occurs while converting message + +USER_NOT_FOUND=User not found: {0} +USER_CREATE_FAILED=User create failed: {0} +USER_UPDATE_FAILED=User update failed: {0} +USER_DELETE_FAILED=User delete failed: {0} +WRONG_UPDATE_PASSWORD_INFORMATION=Wrong old password + +REPOSITORY_NOT_FOUND=Repository not found: {0} +REPOSITORY_ALREADY_EXISTS=Repository already exists: {0} +REPOSITORY_CREATE_FAILED=Repository create failed: {0} +REPOSITORY_UPDATE_FAILED=Repository update failed: {0} +REPOSITORY_DELETE_FAILED=Repository delete failed: {0} + +COLLABORATION_ADD_FAILED=Collaboration add failed: collaborator{0}, repository{1} +COLLABORATION_REMOVE_FAILED=Collaboration remove failed: collaborator{0}, repository{1} +COLLABORATION_ALREADY_EXISTS=Collaboration already exists: collaborator{0}, repository{1} +COLLABORATION_NOT_FOUND=Collaboration not found: collaborator{0}, repository{1} + +SSH_KEY_UPLOAD_FAILED=SSH key upload failed: {0} +SSH_KEY_UPDATE_FAILED=SSH key update failed: {0} +SSH_KEY_DELETE_FAILED=SSH key delete failed: {0} +SSH_KEY_NOT_FOUND=SSH key not found: {0} +SSH_KEY_PUBLIC_KEY_INVALID=Invalid SSH public key: {0} +SSH_KEY_PUBLIC_KEY_ALREADY_EXISTS=SSH public key already exists: {0} +SSH_KEY_NAME_ALREADY_EXISTS=SSH key name already exists: {0} + +OPERATION_NOT_IMPLEMENTED=Operation not implemented + +SERVER_ERROR=Internal server error, try again later + +ILLOGICAL_OPERATION=Illogical operation, please check + +INVALID_EMAIL_VERIFICATION_CODE=Invalid email verification code: {0} diff --git a/src/main/resources/message/exception_zh_CN.properties b/src/main/resources/message/exception_zh_CN.properties new file mode 100644 index 0000000..bcb2b88 --- /dev/null +++ b/src/main/resources/message/exception_zh_CN.properties @@ -0,0 +1,43 @@ +USERNAME_RESERVED=用户名已保留:{0} +USERNAME_ALREADY_EXISTS=用户名已存在:{0} +EMAIL_ALREADY_EXISTS=邮箱已存在:{0} +WRONG_SIGN_IN_INFORMATION=登录信息错误 + +INVALID_TOKEN=无效的令牌:{0} +TOKEN_NOT_FOUND=请求头中未找到令牌 +ACCESS_DENIED=无权限操作 + +MESSAGE_CONVERSION_ERROR=消息转换错误 + +USER_NOT_FOUND=未找到用户:{0} +USER_CREATE_FAILED=创建用户失败:{0} +USER_UPDATE_FAILED=更新用户失败:{0} +USER_DELETE_FAILED=删除用户失败:{0} +WRONG_UPDATE_PASSWORD_INFORMATION=原密码错误 + +REPOSITORY_NOT_FOUND=未找到仓库:{0} +REPOSITORY_ALREADY_EXISTS=仓库已存在:{0} +REPOSITORY_CREATE_FAILED=创建仓库失败:{0} +REPOSITORY_UPDATE_FAILED=更新仓库失败:{0} +REPOSITORY_DELETE_FAILED=删除仓库失败:{0} + +COLLABORATION_ADD_FAILED=添加协作关系失败:协作者{0}, 仓库{1} +COLLABORATION_REMOVE_FAILED=移除协作关系失败:协作者{0}, 仓库{1} +COLLABORATION_ALREADY_EXISTS=协作关系已存在:协作者{0}, 仓库{1} +COLLABORATION_NOT_FOUND=未找到协作关系:协作者{0}, 仓库{1} + +SSH_KEY_UPLOAD_FAILED=上传SSH密钥失败:{0} +SSH_KEY_UPDATE_FAILED=更新SSH密钥失败:{0} +SSH_KEY_DELETE_FAILED=删除SSH密钥失败:{0} +SSH_KEY_NOT_FOUND=未找到SSH密钥:{0} +SSH_KEY_PUBLIC_KEY_INVALID=无效的SSH公钥:{0} +SSH_KEY_PUBLIC_KEY_ALREADY_EXISTS=SSH公钥已存在:{0} +SSH_KEY_NAME_ALREADY_EXISTS=SSH密钥名称已存在:{0} + +OPERATION_NOT_IMPLEMENTED=操作未实现 + +SERVER_ERROR=服务器错误,请稍后再试 + +ILLOGICAL_OPERATION=不合理的操作,请检查 + +INVALID_EMAIL_VERIFICATION_CODE=无效的邮箱验证码:{0} diff --git a/src/main/resources/message/message.properties b/src/main/resources/message/message.properties new file mode 100644 index 0000000..fced7e2 --- /dev/null +++ b/src/main/resources/message/message.properties @@ -0,0 +1,2 @@ +EMAIL_VERIFICATION_CODE_SUBJECT=Email Verification Code +EMAIL_VERIFICATION_CODE_CONTENT=Your email verification code is {0}, this code will expire in {1} minutes. diff --git a/src/main/resources/message/message_zh_CN.properties b/src/main/resources/message/message_zh_CN.properties new file mode 100644 index 0000000..ae5d3cb --- /dev/null +++ b/src/main/resources/message/message_zh_CN.properties @@ -0,0 +1,2 @@ +EMAIL_VERIFICATION_CODE_SUBJECT=邮箱验证码 +EMAIL_VERIFICATION_CODE_CONTENT=您的邮箱验证码是{0},此验证码将在{1}分钟后过期。 diff --git a/src/main/resources/message/validation.properties b/src/main/resources/message/validation.properties new file mode 100644 index 0000000..0ee5dce --- /dev/null +++ b/src/main/resources/message/validation.properties @@ -0,0 +1,70 @@ +VALIDATION_ERROR=Validation error: {0} + +# userCreateDTO validation messages +Size.userCreateDTO.username=Username must be between {2} and {1} characters +NotBlank.userCreateDTO.username=Username cannot be blank +Pattern.userCreateDTO.username=Username can only be alphanumeric, hyphen or underline +NotBlank.userCreateDTO.email=Email cannot be blank +Email.userCreateDTO.email=Email must be a valid email address +Size.userCreateDTO.userPassword=Password must be between {2} and {1} characters +NotBlank.userCreateDTO.userPassword=Password cannot be blank +Pattern.userCreateDTO.userPassword=Password can only be alphanumeric, underline, hyphen, dot or at sign +Size.userCreateDTO.emailVerificationCode=Email verification code must be between {2} and {1} characters +Pattern.userCreateDTO.emailVerificationCode=Email verification code can only be numeric + +# userUpdateDTO validation messages +NotNull.userUpdateDTO.id=User ID cannot be null +Size.userUpdateDTO.username=Username must be between {2} and {1} characters +Pattern.userUpdateDTO.username=Username can only be alphanumeric, hyphen or underline +Size.userUpdateDTO.avatarUrl=Avatar URL must be between {2} and {1} characters + +# sendEmailVerificationCode validation messages +NotBlank.userController#sendEmailVerificationCode.email={NotBlank.userCreateDTO.email} +Email.userController#sendEmailVerificationCode.email={Email.userCreateDTO.email} + +# checkEmailValidity validation messages +Email.userController#checkEmailValidity.email={Email.userCreateDTO.email} +NotBlank.userController#checkEmailValidity.email={NotBlank.userCreateDTO.email} + +# checkUsernameValidity validation messages +NotBlank.userController#checkUsernameValidity.username={NotBlank.userCreateDTO.username} +Size.userController#checkUsernameValidity.username=Username must be between {min} and {max} characters +Pattern.userController#checkUsernameValidity.username={Pattern.userCreateDTO.username} + +# checkPasswordValidity validation messages +NotBlank.userController#checkPasswordValidity.password={NotBlank.userCreateDTO.userPassword} +Size.userController#checkPasswordValidity.password=Password must be between {min} and {max} characters +Pattern.userController#checkPasswordValidity.password={Pattern.userCreateDTO.userPassword} + +# userSignInDTO validation messages +NotBlank.userSignInDTO.username=Username cannot be blank +NotBlank.userSignInDTO.userPassword=Password cannot be blank + +# repositoryDTO validation messages +Null.repositoryDTO.id=Repository ID must be null when creating a new repository +NotNull.repositoryDTO.id=Repository ID cannot be null +Size.repositoryDTO.name=Repository name must be between {2} and {1} characters +NotBlank.repositoryDTO.name=Repository name cannot be blank +Size.repositoryDTO.description=Repository description must be between {2} and {1} characters +Pattern.repositoryDTO.name=Repository name can only be alphanumeric, hyphen or underline + +# checkRepositoryNameValidity validation messages +NotBlank.repositoryController#checkRepositoryNameValidity.repositoryName={NotBlank.repositoryDTO.name} +Size.repositoryController#checkRepositoryNameValidity.repositoryName=Repository name must be between {min} and {max} characters +Pattern.repositoryController#checkRepositoryNameValidity.repositoryName={Pattern.repositoryDTO.name} + +# sshKeyDTO validation messages +Null.sshKeyDTO.id=SSH key ID must be null when creating a new SSH key +NotNull.sshKeyDTO.id=SSH key ID cannot be null +NotBlank.sshKeyDTO.name=SSH key name cannot be blank +Size.sshKeyDTO.name=SSH key name must be between {2} and {1} characters +NotBlank.sshKeyDTO.publicKey=SSH key public key cannot be blank +Size.sshKeyDTO.publicKey=SSH key public key must be between {2} and {1} characters + +# checkSshKeyNameValidity validation messages +NotBlank.sshKeyController#checkSshKeyNameValidity.name={NotBlank.sshKeyDTO.name} +Size.sshKeyController#checkSshKeyNameValidity.name=SSH key name must be between {min} and {max} characters + +# checkSshKeyPublicKeyValidity validation messages +NotBlank.sshKeyController#checkSshKeyPublicKeyValidity.publicKey={NotBlank.sshKeyDTO.publicKey} +Size.sshKeyController#checkSshKeyPublicKeyValidity.publicKey=SSH public key must be between {min} and {max} characters diff --git a/src/main/resources/message/validation_zh_CN.properties b/src/main/resources/message/validation_zh_CN.properties new file mode 100644 index 0000000..6410fdd --- /dev/null +++ b/src/main/resources/message/validation_zh_CN.properties @@ -0,0 +1,64 @@ +VALIDATION_ERROR=校验错误:{0} + +# userCreateDTO validation messages +Size.userCreateDTO.username=用户名必须在{2}至{1}个字符之间 +NotBlank.userCreateDTO.username=用户名不能为空 +Pattern.userCreateDTO.username=用户名只能是字母、数字、连字符或下划线 +NotBlank.userCreateDTO.email=电子邮件不能为空 +Email.userCreateDTO.email=电子邮件必须是有效的电子邮件地址 +Size.userCreateDTO.userPassword=密码必须在{2}至{1}个字符之间 +NotBlank.userCreateDTO.userPassword=密码不能为空 +Pattern.userCreateDTO.userPassword=密码只能是字母、数字、下划线、连字符、点或@符号 +Size.userCreateDTO.emailVerificationCode=电子邮件验证码必须在{2}至{1}个字符之间 +Pattern.userCreateDTO.emailVerificationCode=电子邮件验证码只能是数字 + +# sendEmailVerificationCode validation messages +NotBlank.userController#sendEmailVerificationCode.email={NotBlank.userCreateDTO.email} +Email.userController#sendEmailVerificationCode.email={Email.userCreateDTO.email} + +# checkEmailValidity validation messages +Email.userController#checkEmailValidity.email={Email.userCreateDTO.email} +NotBlank.userController#checkEmailValidity.email={NotBlank.userCreateDTO.email} + +# checkUsernameValidity validation messages +NotBlank.userController#checkUsernameValidity.username={NotBlank.userCreateDTO.username} +Size.userController#checkUsernameValidity.username=用户名必须在{min}至{max}个字符之间 +Pattern.userController#checkUsernameValidity.username={Pattern.userCreateDTO.username} + +# checkPasswordValidity validation messages +NotBlank.userController#checkPasswordValidity.password={NotBlank.userCreateDTO.userPassword} +Size.userController#checkPasswordValidity.password=密码必须在{min}至{max}个字符之间 +Pattern.userController#checkPasswordValidity.password={Pattern.userCreateDTO.userPassword} + +# userSignInDTO validation messages +NotBlank.userSignInDTO.username=用户名不能为空 +NotBlank.userSignInDTO.userPassword=密码不能为空 + +# repositoryDTO validation messages +Null.repositoryDTO.id=在创建新仓库时,仓库ID必须为空 +NotNull.repositoryDTO.id=仓库ID不能为空 +Size.repositoryDTO.name=仓库名称必须在{2}至{1}个字符之间 +NotBlank.repositoryDTO.name=仓库名称不能为空 +Size.repositoryDTO.description=仓库描述必须在{2}至{1}个字符之间 +Pattern.repositoryDTO.name=仓库名称只能是字母、数字、连字符或下划线 + +# checkRepositoryNameValidity validation messages +NotBlank.repositoryController#checkRepositoryNameValidity.repositoryName={NotBlank.repositoryDTO.name} +Size.repositoryController#checkRepositoryNameValidity.repositoryName=仓库名称必须在{min}至{max}个字符之间 +Pattern.repositoryController#checkRepositoryNameValidity.repositoryName={Pattern.repositoryDTO.name} + +# sshKeyDTO validation messages +Null.sshKeyDTO.id=在创建新SSH密钥时,SSH密钥ID必须为空 +NotNull.sshKeyDTO.id=SSH密钥ID不能为空 +NotBlank.sshKeyDTO.name=SSH密钥名称不能为空 +Size.sshKeyDTO.name=SSH密钥名称必须在{2}至{1}个字符之间 +NotBlank.sshKeyDTO.publicKey=SSH密钥公钥不能为空 +Size.sshKeyDTO.publicKey=SSH密钥公钥必须在{2}至{1}个字符之间 + +# checkSshKeyNameValidity validation messages +NotBlank.sshKeyController#checkSshKeyNameValidity.name={NotBlank.sshKeyDTO.name} +Size.sshKeyController#checkSshKeyNameValidity.name=SSH密钥名称必须在{min}至{max}个字符之间 + +# checkSshKeyPublicKeyValidity validation messages +NotBlank.sshKeyController#checkSshKeyPublicKeyValidity.publicKey={NotBlank.sshKeyDTO.publicKey} +Size.sshKeyController#checkSshKeyPublicKeyValidity.publicKey=SSH公钥必顡在{min}至{max}个字符之间 diff --git a/src/main/resources/templates/.gitkeep b/src/main/resources/templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/test/java/edu/cmipt/gcs/SpringBootTestClassOrderer.java b/src/test/java/edu/cmipt/gcs/SpringBootTestClassOrderer.java new file mode 100644 index 0000000..e82c54c --- /dev/null +++ b/src/test/java/edu/cmipt/gcs/SpringBootTestClassOrderer.java @@ -0,0 +1,39 @@ +package edu.cmipt.gcs; + +import edu.cmipt.gcs.controller.AuthenticationControllerTest; +import edu.cmipt.gcs.controller.RepositoryControllerTest; +import edu.cmipt.gcs.controller.SshKeyControllerTest; +import edu.cmipt.gcs.controller.UserControllerTest; + +import org.junit.jupiter.api.ClassDescriptor; +import org.junit.jupiter.api.ClassOrderer; +import org.junit.jupiter.api.ClassOrdererContext; + +import java.util.Comparator; + +public class SpringBootTestClassOrderer implements ClassOrderer { + + private static final Class[] classOrder = + new Class[] { + AuthenticationControllerTest.class, + SshKeyControllerTest.class, + RepositoryControllerTest.class, + UserControllerTest.class + }; + + @Override + public void orderClasses(ClassOrdererContext classOrdererContext) { + classOrdererContext + .getClassDescriptors() + .sort(Comparator.comparingInt(SpringBootTestClassOrderer::getOrder)); + } + + private static int getOrder(ClassDescriptor classDescriptor) { + for (int i = 0; i < classOrder.length; i++) { + if (classDescriptor.getTestClass().equals(classOrder[i])) { + return i; + } + } + return Integer.MAX_VALUE; + } +} diff --git a/src/test/java/edu/cmipt/gcs/config/WebConfigTest.java b/src/test/java/edu/cmipt/gcs/config/WebConfigTest.java new file mode 100644 index 0000000..01754ed --- /dev/null +++ b/src/test/java/edu/cmipt/gcs/config/WebConfigTest.java @@ -0,0 +1,43 @@ +package edu.cmipt.gcs.config; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import edu.cmipt.gcs.constant.ApiPathConstant; +import edu.cmipt.gcs.constant.ApplicationConstant; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({ApplicationConstant.TEST_PROFILE}) +public class WebConfigTest { + @Value("${front-end.url}") + private String frontEndUrl; + + @Autowired private MockMvc mockMvc; + + @Test + public void testCorsFilter() throws Exception { + mockMvc.perform( + options(ApiPathConstant.DEVELOPMENT_GET_API_MAP_API_PATH) + .header("Origin", frontEndUrl + "/INVALID")) + .andExpectAll(status().isForbidden()); + if (frontEndUrl != null && frontEndUrl.length() > 0) { + mockMvc.perform( + options(ApiPathConstant.DEVELOPMENT_GET_API_MAP_API_PATH) + .header("Origin", frontEndUrl) + .header("Access-Control-Request-Method", "GET")) + .andExpectAll( + status().isOk(), + header().string("Access-Control-Allow-Origin", frontEndUrl)); + } + } +} diff --git a/src/test/java/edu/cmipt/gcs/constant/TestConstant.java b/src/test/java/edu/cmipt/gcs/constant/TestConstant.java new file mode 100644 index 0000000..260d9a2 --- /dev/null +++ b/src/test/java/edu/cmipt/gcs/constant/TestConstant.java @@ -0,0 +1,23 @@ +package edu.cmipt.gcs.constant; + +import java.util.Date; + +public class TestConstant { + public static String ID; + public static String USERNAME = new Date().getTime() + ""; + public static String USER_PASSWORD = "123456"; + public static String EMAIL = USERNAME + "@cmipt.edu"; + public static String ACCESS_TOKEN; + public static String REFRESH_TOKEN; + public static String OTHER_ID; + public static String OTHER_USERNAME = new Date().getTime() + "other"; + public static String OTHER_USER_PASSWORD = "123456"; + public static String OTHER_EMAIL = OTHER_USERNAME + "@cmipt.edu"; + public static String OTHER_ACCESS_TOKEN; + public static String OTHER_REFRESH_TOKEN; + public static String REPOSITORY_ID; + public static String REPOSITORY_NAME; + public static Integer REPOSITORY_SIZE = 10; + public static Integer SSH_KEY_SIZE = 10; + public static String SSH_KEY_ID; +} diff --git a/src/test/java/edu/cmipt/gcs/controller/AuthenticationControllerTest.java b/src/test/java/edu/cmipt/gcs/controller/AuthenticationControllerTest.java new file mode 100644 index 0000000..12fb63b --- /dev/null +++ b/src/test/java/edu/cmipt/gcs/controller/AuthenticationControllerTest.java @@ -0,0 +1,251 @@ +package edu.cmipt.gcs.controller; + +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import edu.cmipt.gcs.constant.ApiPathConstant; +import edu.cmipt.gcs.constant.HeaderParameter; +import edu.cmipt.gcs.constant.TestConstant; +import edu.cmipt.gcs.enumeration.ErrorCodeEnum; +import edu.cmipt.gcs.pojo.user.UserVO; +import edu.cmipt.gcs.util.EmailVerificationCodeUtil; +import edu.cmipt.gcs.util.MessageSourceUtil; + +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.Ordered; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +/** + * Tests for AuthenticationController + * + * @author Kaiser + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class AuthenticationControllerTest { + @Autowired private MockMvc mvc; + @Autowired private ObjectMapper objectMapper; + + /** + * Test sign in with invalid user information + * + *

This must excute before {@link #testSignInValid() testSignInValid} + * + * @throws Exception + */ + @Test + @Order(Ordered.HIGHEST_PRECEDENCE) + public void testCreateUserValid() throws Exception { + String userCreateDTO = + """ + { + "username": "%s", + "email": "%s", + "userPassword": "%s", + "emailVerificationCode": "%s" + } + """ + .formatted( + TestConstant.USERNAME, + TestConstant.EMAIL, + TestConstant.USER_PASSWORD, + EmailVerificationCodeUtil.generateVerificationCode( + TestConstant.EMAIL)); + String otherUserCreateDTO = + """ + { + "username": "%s", + "email": "%s", + "userPassword": "%s", + "emailVerificationCode": "%s" + } + """ + .formatted( + TestConstant.OTHER_USERNAME, + TestConstant.OTHER_EMAIL, + TestConstant.OTHER_USER_PASSWORD, + EmailVerificationCodeUtil.generateVerificationCode( + TestConstant.OTHER_EMAIL)); + mvc.perform( + post(ApiPathConstant.USER_CREATE_USER_API_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(userCreateDTO)) + .andExpect(status().isOk()); + mvc.perform( + post(ApiPathConstant.USER_CREATE_USER_API_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(otherUserCreateDTO)) + .andExpect(status().isOk()); + } + + /** + * Test sign in with valid user information + * + *

This must excute after {@link #testSignInValid() testSignInValid}, and before {@link + * #testRefreshValid() testRefreshValid} + * + * @throws Exception + */ + @Test + @Order(Ordered.HIGHEST_PRECEDENCE + 1) + public void testSignInValid() throws Exception { + String userSignInDTO = + """ + { + "username": "%s", + "userPassword": "%s" + } + """ + .formatted(TestConstant.USERNAME, TestConstant.USER_PASSWORD); + String otherUserSignInDTO = + """ + { + "username": "%s", + "userPassword": "%s" + } + """ + .formatted(TestConstant.OTHER_USERNAME, TestConstant.OTHER_USER_PASSWORD); + + var response = + mvc.perform( + post(ApiPathConstant.AUTHENTICATION_SIGN_IN_API_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(userSignInDTO)) + .andExpectAll( + status().isOk(), + jsonPath("$.username", is(TestConstant.USERNAME)), + jsonPath("$.email", is(TestConstant.EMAIL)), + jsonPath("$.id").isString(), + header().exists(HeaderParameter.ACCESS_TOKEN), + header().exists(HeaderParameter.REFRESH_TOKEN)) + .andReturn() + .getResponse(); + TestConstant.ACCESS_TOKEN = response.getHeader(HeaderParameter.ACCESS_TOKEN); + TestConstant.REFRESH_TOKEN = response.getHeader(HeaderParameter.REFRESH_TOKEN); + TestConstant.ID = objectMapper.readValue(response.getContentAsString(), UserVO.class).id(); + var otherResponse = + mvc.perform( + post(ApiPathConstant.AUTHENTICATION_SIGN_IN_API_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(otherUserSignInDTO)) + .andExpectAll( + status().isOk(), + jsonPath("$.username", is(TestConstant.OTHER_USERNAME)), + jsonPath("$.email", is(TestConstant.OTHER_EMAIL)), + jsonPath("$.id").isString(), + header().exists(HeaderParameter.ACCESS_TOKEN), + header().exists(HeaderParameter.REFRESH_TOKEN)) + .andReturn() + .getResponse(); + TestConstant.OTHER_ID = + objectMapper.readValue(otherResponse.getContentAsString(), UserVO.class).id(); + TestConstant.OTHER_ACCESS_TOKEN = otherResponse.getHeader(HeaderParameter.ACCESS_TOKEN); + TestConstant.OTHER_REFRESH_TOKEN = otherResponse.getHeader(HeaderParameter.REFRESH_TOKEN); + } + + /** + * Test refresh token with valid refresh token + * + *

This must excute after {@link #testSignInValid() testSignInValid} + * + * @throws Exception + */ + @Test + @Order(Ordered.HIGHEST_PRECEDENCE + 2) + public void testRefreshValid() throws Exception { + mvc.perform( + get(ApiPathConstant.AUTHENTICATION_REFRESH_API_PATH) + .header(HeaderParameter.REFRESH_TOKEN, TestConstant.REFRESH_TOKEN)) + .andExpectAll(status().isOk(), header().exists(HeaderParameter.ACCESS_TOKEN)); + } + + @Test + public void testSignInInvalid() throws Exception { + String invalidUserSignInDTO = + """ + { + "username": "%s", + "userPassword": "%s" + } + """ + .formatted(TestConstant.USERNAME, TestConstant.USER_PASSWORD + "wrong"); + + mvc.perform( + post(ApiPathConstant.AUTHENTICATION_SIGN_IN_API_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidUserSignInDTO)) + .andExpectAll( + status().isBadRequest(), + content() + .json( + """ + { + "code": %d, + "message": "%s" + } + """ + .formatted( + ErrorCodeEnum.WRONG_SIGN_IN_INFORMATION + .ordinal(), + MessageSourceUtil.getMessage( + ErrorCodeEnum + .WRONG_SIGN_IN_INFORMATION)))); + } + + @Test + public void testCreateUserInvalid() throws Exception { + String invalidUserCreateDTO = + """ + { + "username": "test", + "email": "invalid email address", + "userPassword": "123456" + } + """; + mvc.perform( + post(ApiPathConstant.USER_CREATE_USER_API_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidUserCreateDTO)) + .andExpectAll( + status().isBadRequest(), + jsonPath("$.code", is(ErrorCodeEnum.VALIDATION_ERROR.ordinal()))); + } + + @Test + public void testRefreshInvalid() throws Exception { + String invalidToken = "This is an invalid token"; + mvc.perform( + get(ApiPathConstant.AUTHENTICATION_REFRESH_API_PATH) + .header(HeaderParameter.REFRESH_TOKEN, invalidToken)) + .andExpectAll( + status().isUnauthorized(), + content() + .json( + """ + { + "code": %d, + "message": "%s" + } + """ + .formatted( + ErrorCodeEnum.INVALID_TOKEN.ordinal(), + MessageSourceUtil.getMessage( + ErrorCodeEnum.INVALID_TOKEN, + invalidToken)))); + } +} diff --git a/src/test/java/edu/cmipt/gcs/controller/RepositoryControllerTest.java b/src/test/java/edu/cmipt/gcs/controller/RepositoryControllerTest.java new file mode 100644 index 0000000..145205c --- /dev/null +++ b/src/test/java/edu/cmipt/gcs/controller/RepositoryControllerTest.java @@ -0,0 +1,206 @@ +package edu.cmipt.gcs.controller; + +import static org.hamcrest.Matchers.greaterThan; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import edu.cmipt.gcs.constant.ApiPathConstant; +import edu.cmipt.gcs.constant.HeaderParameter; +import edu.cmipt.gcs.constant.TestConstant; +import edu.cmipt.gcs.pojo.other.PageVO; +import edu.cmipt.gcs.pojo.repository.RepositoryVO; + +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.Ordered; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +/** + * Tests for RepositoryController + * + * @author Kaiser + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class RepositoryControllerTest { + @Autowired private ObjectMapper objectMapper; + @Autowired private MockMvc mvc; + + @Test + @Order(Ordered.HIGHEST_PRECEDENCE) + public void testCreateRepositoryValid() throws Exception { + String repositoryName = ""; + for (int i = 0; i < TestConstant.REPOSITORY_SIZE; i++) { + repositoryName = String.valueOf(i); + mvc.perform( + post(ApiPathConstant.REPOSITORY_CREATE_REPOSITORY_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "repositoryName": "%s", + "isPrivate": %s + } + """ + .formatted( + repositoryName, + i % 2 == 0 ? "false" : "true"))) + .andExpect(status().isOk()); + } + var content = + mvc.perform( + get(ApiPathConstant.REPOSITORY_PAGE_REPOSITORY_API_PATH) + .header( + HeaderParameter.ACCESS_TOKEN, + TestConstant.ACCESS_TOKEN) + .param("id", TestConstant.ID) + .param("page", "1") + .param("size", TestConstant.REPOSITORY_SIZE.toString())) + .andExpectAll( + status().isOk(), + jsonPath("$.pages").value(greaterThan(0)), + jsonPath("$.records").isArray(), + jsonPath("$.records.length()").value(TestConstant.REPOSITORY_SIZE)) + .andReturn() + .getResponse() + .getContentAsString(); + var pageVO = objectMapper.readValue(content, new TypeReference>() {}); + TestConstant.REPOSITORY_ID = pageVO.records().get(0).id(); + TestConstant.REPOSITORY_NAME = pageVO.records().get(0).repositoryName(); + } + + @Test + @Order(Ordered.HIGHEST_PRECEDENCE + 1) + public void testUpdateRepositoryValid() throws Exception { + String newDescription = "This is a test description"; + mvc.perform( + post(ApiPathConstant.REPOSITORY_UPDATE_REPOSITORY_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "id": "%s", + "repositoryDescription": "%s" + } + """ + .formatted( + TestConstant.REPOSITORY_ID, + newDescription))) + .andExpectAll( + status().isOk(), + jsonPath("$.id").value(TestConstant.REPOSITORY_ID), + jsonPath("$.repositoryName").value(TestConstant.REPOSITORY_NAME), + jsonPath("$.repositoryDescription").value(newDescription), + jsonPath("$.userId").value(TestConstant.ID), + jsonPath("$.star").value(0), + jsonPath("$.fork").value(0), + jsonPath("$.watcher").value(0)); + } + + @Test + @Order(Ordered.HIGHEST_PRECEDENCE + 2) + public void testAddCollaboratorValid() throws Exception { + mvc.perform( + post(ApiPathConstant.REPOSITORY_ADD_COLLABORATOR_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .param("repositoryId", TestConstant.REPOSITORY_ID) + .param("collaborator", TestConstant.OTHER_ID) + .param("collaboratorType", "id")) + .andExpect(status().isOk()); + } + + @Test + @Order(Ordered.HIGHEST_PRECEDENCE + 3) + public void testPageCollaboratorValid() throws Exception { + mvc.perform( + get(ApiPathConstant.REPOSITORY_PAGE_COLLABORATOR_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .param("repositoryId", TestConstant.REPOSITORY_ID) + .param("page", "1") + .param("size", "10")) + .andExpectAll( + status().isOk(), + jsonPath("$.pages").value(greaterThan(0)), + jsonPath("$.total").value(greaterThan(0)), + jsonPath("$.records").isArray(), + jsonPath("$.records.length()").value(1), + jsonPath("$.records[0].id").value(TestConstant.OTHER_ID), + jsonPath("$.records[0].username").value(TestConstant.OTHER_USERNAME), + jsonPath("$.records[0].email").value(TestConstant.OTHER_EMAIL)); + } + + @Test + @Order(Ordered.HIGHEST_PRECEDENCE + 4) + public void testRemoveCollaborationValid() throws Exception { + mvc.perform( + delete(ApiPathConstant.REPOSITORY_REMOVE_COLLABORATION_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .param("repositoryId", TestConstant.REPOSITORY_ID) + .param("collaboratorId", TestConstant.OTHER_ID)) + .andExpect(status().isOk()); + } + + @Test + @Order(Ordered.HIGHEST_PRECEDENCE + 5) + public void testDeleteRepositoryValid() throws Exception { + mvc.perform( + delete(ApiPathConstant.REPOSITORY_DELETE_REPOSITORY_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .param("id", TestConstant.REPOSITORY_ID)) + .andExpect(status().isOk()); + TestConstant.REPOSITORY_ID = null; + TestConstant.REPOSITORY_NAME = null; + TestConstant.REPOSITORY_SIZE--; + } + + @Test + @Order(Ordered.HIGHEST_PRECEDENCE + 6) + public void testPageUserRepositoryValid() throws Exception { + mvc.perform( + get(ApiPathConstant.REPOSITORY_PAGE_REPOSITORY_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .param("id", TestConstant.ID) + .param("page", "1") + .param("size", TestConstant.REPOSITORY_SIZE.toString())) + .andExpectAll( + status().isOk(), + jsonPath("$.pages").value(greaterThan(0)), + jsonPath("$.total").value(greaterThan(0)), + jsonPath("$.records").isArray(), + jsonPath("$.records.length()").value(TestConstant.REPOSITORY_SIZE)); + } + + @Test + @Order(Ordered.HIGHEST_PRECEDENCE + 7) + public void testPageOtherUserRepositoryValid() throws Exception { + mvc.perform( + get(ApiPathConstant.REPOSITORY_PAGE_REPOSITORY_API_PATH) + .header( + HeaderParameter.ACCESS_TOKEN, + TestConstant.OTHER_ACCESS_TOKEN) + .param("id", TestConstant.ID) + .param("page", "1") + .param("size", TestConstant.REPOSITORY_SIZE.toString())) + .andExpectAll( + status().isOk(), + jsonPath("$.pages").value(greaterThan(0)), + jsonPath("$.total").value(greaterThan(0)), + jsonPath("$.records").isArray(), + jsonPath("$.records.length()").value(TestConstant.REPOSITORY_SIZE / 2)); + } +} diff --git a/src/test/java/edu/cmipt/gcs/controller/SshKeyControllerTest.java b/src/test/java/edu/cmipt/gcs/controller/SshKeyControllerTest.java new file mode 100644 index 0000000..54f4d62 --- /dev/null +++ b/src/test/java/edu/cmipt/gcs/controller/SshKeyControllerTest.java @@ -0,0 +1,132 @@ +package edu.cmipt.gcs.controller; + +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import edu.cmipt.gcs.constant.ApiPathConstant; +import edu.cmipt.gcs.constant.HeaderParameter; +import edu.cmipt.gcs.constant.TestConstant; +import edu.cmipt.gcs.pojo.other.PageVO; +import edu.cmipt.gcs.pojo.ssh.SshKeyVO; + +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.Ordered; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class SshKeyControllerTest { + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper objectMapper; + + @Test + @Order(Ordered.HIGHEST_PRECEDENCE) + public void testUploadSshKeyValid() throws Exception { + for (int i = 0; i < TestConstant.SSH_KEY_SIZE; i++) { + String name = "My SSH Key " + i; + String publicKey = "This is my public key " + i; + mockMvc.perform( + post(ApiPathConstant.SSH_KEY_UPLOAD_SSH_KEY_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "name": "%s", + "userId": "%s", + "publicKey": "%s" + } + """ + .formatted(name, TestConstant.ID, publicKey))) + .andExpect(status().isOk()); + } + } + + @Test + @Order(Ordered.HIGHEST_PRECEDENCE + 1) + public void testPageSshKeyValid() throws Exception { + var content = + mockMvc.perform( + get(ApiPathConstant.SSH_KEY_PAGE_SSH_KEY_API_PATH) + .header( + HeaderParameter.ACCESS_TOKEN, + TestConstant.ACCESS_TOKEN) + .param("id", TestConstant.ID) + .param("page", "1") + .param("size", TestConstant.SSH_KEY_SIZE.toString())) + .andExpectAll( + status().isOk(), + jsonPath("$.pages").value(greaterThan(0)), + jsonPath("$.total").value(greaterThan(0)), + jsonPath("$.records").isArray(), + jsonPath("$.records.length()").value(TestConstant.SSH_KEY_SIZE)) + .andReturn() + .getResponse() + .getContentAsString(); + var pageVO = objectMapper.readValue(content, new TypeReference>() {}); + TestConstant.SSH_KEY_ID = pageVO.records().get(0).id(); + } + + @Test + @Order(Ordered.HIGHEST_PRECEDENCE + 2) + public void testUpdateSshKeyValid() throws Exception { + mockMvc.perform( + post(ApiPathConstant.SSH_KEY_UPDATE_SSH_KEY_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "id": "%s", + "name": "My SSH Key Updated", + "userId": "%s", + "publicKey": "This is my public key updated" + } + """ + .formatted( + TestConstant.SSH_KEY_ID, TestConstant.ID))) + .andExpectAll( + status().isOk(), + jsonPath("$.id", is(TestConstant.SSH_KEY_ID)), + jsonPath("$.userId", is(TestConstant.ID)), + jsonPath("$.name", is("My SSH Key Updated")), + jsonPath("$.publicKey", is("This is my public key updated"))); + } + + /** + * Test delete ssh-key + * + *

This must excute after {@link #testUpdateSshKeyValid() testUploadSshKeyValid} + * + * @throws Exception + */ + @Test + @Order(Ordered.HIGHEST_PRECEDENCE + 3) + public void testDeleteSshKeyValid() throws Exception { + mockMvc.perform( + delete(ApiPathConstant.SSH_KEY_DELETE_SSH_KEY_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .param("id", TestConstant.SSH_KEY_ID)) + .andExpect(status().isOk()); + TestConstant.SSH_KEY_ID = null; + TestConstant.SSH_KEY_SIZE--; + // check if the size has been decreased + testPageSshKeyValid(); + } +} diff --git a/src/test/java/edu/cmipt/gcs/controller/UserControllerTest.java b/src/test/java/edu/cmipt/gcs/controller/UserControllerTest.java new file mode 100644 index 0000000..fd88b69 --- /dev/null +++ b/src/test/java/edu/cmipt/gcs/controller/UserControllerTest.java @@ -0,0 +1,346 @@ +package edu.cmipt.gcs.controller; + +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import edu.cmipt.gcs.constant.ApiPathConstant; +import edu.cmipt.gcs.constant.HeaderParameter; +import edu.cmipt.gcs.constant.TestConstant; +import edu.cmipt.gcs.enumeration.ErrorCodeEnum; +import edu.cmipt.gcs.util.EmailVerificationCodeUtil; +import edu.cmipt.gcs.util.MessageSourceUtil; + +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.Ordered; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Date; + +/** + * Tests for UserController + * + * @author Kaiser + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class UserControllerTest { + @Autowired private MockMvc mvc; + + @Test + public void testGetUserValid() throws Exception { + mvc.perform( + get(ApiPathConstant.USER_GET_USER_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .param("user", TestConstant.USERNAME) + .param("userType", "username")) + .andExpectAll( + status().isOk(), + jsonPath("$.username", is(TestConstant.USERNAME)), + jsonPath("$.email", is(TestConstant.EMAIL)), + jsonPath("$.id").isString()); + } + + @Test + public void testGetUserInvalid() throws Exception { + String invalidUsername = TestConstant.USERNAME + "invalid"; + mvc.perform( + get(ApiPathConstant.USER_GET_USER_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .param("user", invalidUsername) + .param("userType", "username")) + .andExpectAll( + status().isNotFound(), + content() + .json( + """ + { + "code": %d, + "message": "%s" + } + """ + .formatted( + ErrorCodeEnum.USER_NOT_FOUND.ordinal(), + MessageSourceUtil.getMessage( + ErrorCodeEnum.USER_NOT_FOUND, + invalidUsername)))); + } + + @Test + public void testCheckEmailValidityExists() throws Exception { + mvc.perform( + get(ApiPathConstant.USER_CHECK_EMAIL_VALIDITY_API_PATH) + .param("email", TestConstant.EMAIL)) + .andExpectAll( + status().isBadRequest(), + content() + .json( + """ + { + "code": %d, + "message": "%s" + } + """ + .formatted( + ErrorCodeEnum.EMAIL_ALREADY_EXISTS + .ordinal(), + MessageSourceUtil.getMessage( + ErrorCodeEnum.EMAIL_ALREADY_EXISTS, + TestConstant.EMAIL)))); + } + + @Test + public void testCheckEmailValidityValid() throws Exception { + mvc.perform( + get(ApiPathConstant.USER_CHECK_EMAIL_VALIDITY_API_PATH) + .param("email", new Date().getTime() + "@cmipt.edu")) + .andExpectAll(status().isOk()); + } + + @Test + public void testCheckUsernameValidityExists() throws Exception { + mvc.perform( + get(ApiPathConstant.USER_CHECK_USERNAME_VALIDITY_API_PATH) + .param("username", TestConstant.USERNAME)) + .andExpectAll( + status().isBadRequest(), + content() + .json( + """ + { + "code": %d, + "message": "%s" + } + """ + .formatted( + ErrorCodeEnum.USERNAME_ALREADY_EXISTS + .ordinal(), + MessageSourceUtil.getMessage( + ErrorCodeEnum + .USERNAME_ALREADY_EXISTS, + TestConstant.USERNAME)))); + } + + @Test + public void testCheckUsernameValidityValid() throws Exception { + mvc.perform( + get(ApiPathConstant.USER_CHECK_USERNAME_VALIDITY_API_PATH) + .param("username", new Date().getTime() + "")) + .andExpectAll(status().isOk()); + } + + @Test + public void testUpdateUserValid() throws Exception { + TestConstant.USERNAME += new Date().getTime() + "new"; + mvc.perform( + post(ApiPathConstant.USER_UPDATE_USER_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "id": "%s", + "username": "%s" + } + """ + .formatted(TestConstant.ID, TestConstant.USERNAME))) + .andExpectAll( + status().isOk(), + jsonPath("$.username", is(TestConstant.USERNAME)), + jsonPath("$.id").isString()); + } + + @Test + public void testUpdateUserInvalid() throws Exception { + String otherID = "123"; + mvc.perform( + post(ApiPathConstant.USER_UPDATE_USER_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "id": "%s", + "username": "%s" + } + """ + .formatted(otherID, TestConstant.USERNAME))) + .andExpectAll( + status().isForbidden(), + content() + .json( + """ + { + "code": %d, + "message": "%s" + } + """ + .formatted( + ErrorCodeEnum.ACCESS_DENIED.ordinal(), + MessageSourceUtil.getMessage( + ErrorCodeEnum.ACCESS_DENIED)))); + } + + @Test + public void testUpdateUserPasswordWithOldPasswordValid() throws Exception { + mvc.perform( + post(ApiPathConstant.USER_UPDATE_USER_PASSWORD_WITH_OLD_PASSWORD_API_PATH) + .param("id", TestConstant.ID) + .param("oldPassword", TestConstant.USER_PASSWORD) + .param("newPassword", TestConstant.USER_PASSWORD + "new")) + .andExpectAll(status().isOk()); + TestConstant.USER_PASSWORD += "new"; + String userSignInDTO = + """ + { + "username": "%s", + "userPassword": "%s" + } + """ + .formatted(TestConstant.USERNAME, TestConstant.USER_PASSWORD); + // get the new tokens + var response = + mvc.perform( + post(ApiPathConstant.AUTHENTICATION_SIGN_IN_API_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(userSignInDTO)) + .andReturn() + .getResponse(); + TestConstant.ACCESS_TOKEN = response.getHeader(HeaderParameter.ACCESS_TOKEN); + TestConstant.REFRESH_TOKEN = response.getHeader(HeaderParameter.REFRESH_TOKEN); + } + + @Test + public void testUpdateUserPasswordWithOldPasswordInvalid() throws Exception { + mvc.perform( + post(ApiPathConstant.USER_UPDATE_USER_PASSWORD_WITH_OLD_PASSWORD_API_PATH) + .param("id", TestConstant.ID) + .param("oldPassword", TestConstant.USER_PASSWORD + "wrong") + .param("newPassword", TestConstant.USER_PASSWORD + "new")) + .andExpectAll( + status().isBadRequest(), + content() + .json( + """ + { + "code": %d, + "message": "%s" + } + """ + .formatted( + ErrorCodeEnum + .WRONG_UPDATE_PASSWORD_INFORMATION + .ordinal(), + MessageSourceUtil.getMessage( + ErrorCodeEnum + .WRONG_UPDATE_PASSWORD_INFORMATION)))); + } + + @Test + public void testUpdateUserPasswordWithEmailVerificationCodeValid() throws Exception { + mvc.perform( + post(ApiPathConstant + .USER_UPDATE_USER_PASSWORD_WITH_EMAIL_VERIFICATION_CODE_API_PATH) + .param("email", TestConstant.EMAIL) + .param( + "emailVerificationCode", + EmailVerificationCodeUtil.generateVerificationCode( + TestConstant.EMAIL)) + .param("newPassword", TestConstant.USER_PASSWORD + "new")) + .andExpectAll(status().isOk()); + TestConstant.USER_PASSWORD += "new"; + String userSignInDTO = + """ + { + "username": "%s", + "userPassword": "%s" + } + """ + .formatted(TestConstant.USERNAME, TestConstant.USER_PASSWORD); + // get the new tokens + var response = + mvc.perform( + post(ApiPathConstant.AUTHENTICATION_SIGN_IN_API_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(userSignInDTO)) + .andReturn() + .getResponse(); + TestConstant.ACCESS_TOKEN = response.getHeader(HeaderParameter.ACCESS_TOKEN); + TestConstant.REFRESH_TOKEN = response.getHeader(HeaderParameter.REFRESH_TOKEN); + } + + @Test + public void testUpdateUserPasswordWithEmailVerificationCodeInvalid() throws Exception { + mvc.perform( + post(ApiPathConstant + .USER_UPDATE_USER_PASSWORD_WITH_EMAIL_VERIFICATION_CODE_API_PATH) + .param("email", TestConstant.EMAIL) + .param("emailVerificationCode", "123456") + .param("newPassword", TestConstant.USER_PASSWORD + "new")) + .andExpectAll( + status().isBadRequest(), + content() + .json( + """ + { + "code": %d, + "message": "%s" + } + """ + .formatted( + ErrorCodeEnum + .INVALID_EMAIL_VERIFICATION_CODE + .ordinal(), + MessageSourceUtil.getMessage( + ErrorCodeEnum + .INVALID_EMAIL_VERIFICATION_CODE, + "123456")))); + } + + @Test + public void testDeleteUserInvalid() throws Exception { + String otherID = "123"; + mvc.perform( + delete(ApiPathConstant.USER_DELETE_USER_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .header(HeaderParameter.REFRESH_TOKEN, TestConstant.REFRESH_TOKEN) + .param("id", otherID)) + .andExpectAll( + status().isForbidden(), + content() + .json( + """ + { + "code": %d, + "message": "%s" + } + """ + .formatted( + ErrorCodeEnum.ACCESS_DENIED.ordinal(), + MessageSourceUtil.getMessage( + ErrorCodeEnum.ACCESS_DENIED)))); + } + + @Test + @Order(Ordered.LOWEST_PRECEDENCE) + public void testDeleteUserValid() throws Exception { + mvc.perform( + delete(ApiPathConstant.USER_DELETE_USER_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .header(HeaderParameter.REFRESH_TOKEN, TestConstant.REFRESH_TOKEN) + .param("id", TestConstant.ID)) + .andExpectAll(status().isOk()); + } +} diff --git a/src/test/resources/junit-platform.properties b/src/test/resources/junit-platform.properties new file mode 100644 index 0000000..5a0cb8f --- /dev/null +++ b/src/test/resources/junit-platform.properties @@ -0,0 +1 @@ +junit.jupiter.testclass.order.default=edu.cmipt.gcs.SpringBootTestClassOrderer