diff --git a/.ci/E2E-tests/execute.sh b/.ci/E2E-tests/execute.sh
index 971d05dea8a6..a75ebfb382e2 100755
--- a/.ci/E2E-tests/execute.sh
+++ b/.ci/E2E-tests/execute.sh
@@ -23,12 +23,3 @@ cd docker
docker compose -f $COMPOSE_FILE pull artemis-cypress $DB nginx
docker compose -f $COMPOSE_FILE build --build-arg WAR_FILE_STAGE=external_builder --no-cache --pull artemis-app
docker compose -f $COMPOSE_FILE up --exit-code-from artemis-cypress
-exitCode=$?
-cd ..
-echo "Cypress container exit code: $exitCode"
-if [ $exitCode -eq 0 ]
-then
- touch .successful
-else
- echo "Not creating success file because the tests failed"
-fi
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 8d07af38982d..5e0eb0a988fd 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -120,14 +120,14 @@ jobs:
uses: docker/setup-buildx-action@v3
# Build and Push to GitHub Container Registry
- name: Login to GitHub Container Registry
- uses: docker/login-action@v2
+ uses: docker/login-action@v3
if: ${{ steps.compute-tag.outputs.result != 'FALSE' }}
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push to GitHub Container Registry
- uses: docker/build-push-action@v4
+ uses: docker/build-push-action@v5
if: ${{ steps.compute-tag.outputs.result != 'FALSE' }}
with:
# beware that the linux/arm64 build from the registry is using an amd64 compiled .war file as
diff --git a/README.md b/README.md
index 73b97ae88f5e..69f118e11e07 100644
--- a/README.md
+++ b/README.md
@@ -41,10 +41,11 @@
15. **[Learning analytics](https://ls1intum.github.io/Artemis/user/learning-analytics/)**: Artemis integrated different statistics for students to compare themselves to the course average. It allows instructors to evaluate the average student performance based on exercises and competencies.
16. **[Adaptive Learning](https://ls1intum.github.io/Artemis/user/adaptive-learning/)**: Artemis allows instructors and students to define and track competencies. Students can monitor their progress towards these goals, while instructors can provide tailored feedback. This approach integrates lectures and exercises under overarching learning objectives.
17. **[Tutorial Groups](https://ls1intum.github.io/Artemis/user/tutorialgroups/)**: Artemis support the management of tutorial groups of a course. This includes planning the sessions, assigning responsible tutors, registering students and tracking the attendance.
-18. **[Scalable](https://ls1intum.github.io/Artemis/user/scaling/)**: Artemis scales to multiple courses with thousands of students. In fact, the largest course had 2,400 students. Administrators can easily scale Artemis with additional build agents in the continuous integration environment.
-19. **[High user satisfaction](https://ls1intum.github.io/Artemis/user/user-experience/)**: Artemis is easy to use, provides guided tutorials. Developers focus on usability, user experience, and performance.
-20. **Customizable**: It supports multiple instructors, editors, and tutors per course and allows instructors to customize many course settings
-21. **[Open-source](https://ls1intum.github.io/Artemis/dev/open-source/)**: Free to use with a large community and many active maintainers.
+18. **[Iris](https://artemis.cit.tum.de/about-iris)**: Artemis integrates Iris, a chatbot that supports students and instructors with common questions and tasks.
+19. **[Scalable](https://ls1intum.github.io/Artemis/user/scaling/)**: Artemis scales to multiple courses with thousands of students. In fact, the largest course had 2,400 students. Administrators can easily scale Artemis with additional build agents in the continuous integration environment.
+20. **[High user satisfaction](https://ls1intum.github.io/Artemis/user/user-experience/)**: Artemis is easy to use, provides guided tutorials. Developers focus on usability, user experience, and performance.
+21. **Customizable**: It supports multiple instructors, editors, and tutors per course and allows instructors to customize many course settings
+22. **[Open-source](https://ls1intum.github.io/Artemis/dev/open-source/)**: Free to use with a large community and many active maintainers.
## Roadmap
diff --git a/docker/atlassian.yml b/docker/atlassian.yml
index cf5b1047797b..6b8e33418747 100644
--- a/docker/atlassian.yml
+++ b/docker/atlassian.yml
@@ -11,7 +11,7 @@ services:
image: ghcr.io/ls1intum/artemis-jira:9.4.3
pull_policy: always
volumes:
- - artemis-jira-data:/var/atlassian/application-data/jira
+ - artemis-jira-data:/var/atlassian/application-data/jira
ports:
- "8081:8080"
# expose the port to make it reachable docker internally even if the external port mapping changes
@@ -57,6 +57,12 @@ services:
- "8085"
networks:
- artemis
+ healthcheck:
+ test: curl -f http://localhost:8085/rest/api/latest/server | grep "RUNNING"
+ interval: 10s
+ timeout: 5s
+ start_period: 40s
+ retries: 120 # = 20 minutes startup time during setup
bamboo-build-agent:
container_name: artemis-bamboo-build-agent
@@ -74,6 +80,9 @@ services:
BAMBOO_SERVER: "http://bamboo:8085"
networks:
- artemis
+ depends_on:
+ bamboo:
+ condition: service_healthy
networks:
artemis:
diff --git a/docs/.readthedocs.yaml b/docs/.readthedocs.yaml
index 1a83fba70835..9522486c382b 100644
--- a/docs/.readthedocs.yaml
+++ b/docs/.readthedocs.yaml
@@ -5,6 +5,8 @@ build:
os: ubuntu-22.04
tools:
python: "3.10"
+sphinx:
+ fail_on_warning: true
python:
install:
- requirements: docs/requirements.txt
diff --git a/docs/Makefile b/docs/Makefile
index 3473b9c8d84f..e7c2e9cec73a 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -3,7 +3,8 @@
# You can set these variables from the command line, and also
# from the environment for the first two.
-SPHINXOPTS ?=
+# -W: treat warnings as errors
+SPHINXOPTS ?= -W
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
diff --git a/docs/admin/pyris-setup.rst b/docs/admin/pyris-setup.rst
index 1ebe29de6586..372ddeea6116 100644
--- a/docs/admin/pyris-setup.rst
+++ b/docs/admin/pyris-setup.rst
@@ -36,7 +36,7 @@ E.g.: ``cp Pyris/application.example.yml application.yml``
Now you need to configure the ``application.yml`` file. Here is an example configuration:
-.. code-block:: yml
+.. code-block:: yaml
pyris:
api_keys:
diff --git a/docs/dev/cypress.rst b/docs/dev/cypress.rst
index c38dd0767f8c..d8131628da3d 100644
--- a/docs/dev/cypress.rst
+++ b/docs/dev/cypress.rst
@@ -30,7 +30,7 @@ Follow these steps to create your local cypress instance:
2. Customize Cypress settings
- To connect cypress to our local Artemis instance, we need to adjust some configurations.
+ To connect cypress to our local Artemis instance, we need to adjust some configurations.
First we need to set the URL or IP of the Artemis instance in the ``cypress.config.ts`` file.
Adjust the ``baseUrl`` setting to fit your setup (e.g. ``baseUrl: 'http://localhost:9000',``)
@@ -39,7 +39,7 @@ Follow these steps to create your local cypress instance:
We also need to adjust the user setting, which will determine the usernames and passwords, that cypress
will use. These settings are located within the ``cypress.env.json`` file. If you use the Atlassian setup,
the file should typically look like this:
-
+
.. code-block:: json
{
@@ -54,8 +54,9 @@ Follow these steps to create your local cypress instance:
"instructorGroupName": "instructors"
}
- The ``USERID`` part will be automatically replaced by different user ids. These are set within the ``support/users.ts`` file.
+ The ``USERID`` part will be automatically replaced by different user ids. These are set within the ``support/users.ts`` file.
For a typical local installation the IDs are:
+
- studentOne: 1
- studentTwo: 2
- studentThree: 3
@@ -87,7 +88,7 @@ Follow these steps to create your local cypress instance:
:align: center
:alt: Cypress cypress-open-screenshot
- You can now click on any test suite and it should run.
+ You can now click on any test suite and it should run.
.. warning::
**IMPORTANT**: If you run the E2E tests for the first time, always run the ``ImportUsers.ts`` tests first,
@@ -97,18 +98,18 @@ Follow these steps to create your local cypress instance:
Debug using Sorry Cypress
-------------------------
-Since the E2E tests are sometimes hard to debug, we provide a dashboard, that allows to inspect the
-CI run and even watch a video of the UI interaction with Artemis in that run.
+Since the E2E tests are sometimes hard to debug, we provide a dashboard, that allows to inspect the
+CI run and even watch a video of the UI interaction with Artemis in that run.
It's based on Sorry Cypress a open source and selfhostable alternative to the paid cypress cloud.
The dashboard itself can be access here: https://sorry-cypress.ase.cit.tum.de/
-To access it, you need these basic auth credentials (sorry cypress itself does not provide an auth
+To access it, you need these basic auth credentials (sorry cypress itself does not provide an auth
system, so we are forced to use nginx basic auth here). You can find these credentials on our confluence page:
https://confluence.ase.in.tum.de/display/ArTEMiS/Sorry+Cypress+Dashboard
-After that you will see the initial dashboard.
+After that you will see the initial dashboard.
You first have to select a project in the left sidebar (mysql or postgresql):
@@ -122,31 +123,31 @@ Now you get a list of the last runs. In the top right you can enter your branch
:align: center
:alt: Sorry Cypress last runs
-The name of the run consists of the branch name followed by the run number. The last part is MySQL or
-PostgreSQL depending on the run environment. If you are in the MySQL project, you will of course only see the MySQL runs.
+The name of the run consists of the branch name followed by the run number. The last part is MySQL or
+PostgreSQL depending on the run environment. If you are in the MySQL project, you will of course only see the MySQL runs.
-If you now click on the run, you can see detailed information about the test suites (corresponding
+If you now click on the run, you can see detailed information about the test suites (corresponding
to components within Artemis). For each suite there is information about the run time, the successful/failed/flaky/skipped/ignored tests:
.. figure:: cypress/sorry-cypress-run.png
:align: center
:alt: Sorry Cypress single run
-If you want to further debug one test suite, just click on it.
+If you want to further debug one test suite, just click on it.
.. figure:: cypress/sorry-cypress-test.png
:align: center
:alt: Sorry Cypress single test
-Here you can see the single tests on the left and a video on the right. This is a screen capture of
-the actual run and can tremendously help debug failing E2E tests.
+Here you can see the single tests on the left and a video on the right. This is a screen capture of
+the actual run and can tremendously help debug failing E2E tests.
-Sometimes the video can be a little bit to fast to debug easily. Just download the video on your
-computer and play it with a video player, that allows you to slow the video down.
+Sometimes the video can be a little bit to fast to debug easily. Just download the video on your
+computer and play it with a video player, that allows you to slow the video down.
.. note::
- For maintenance reasons videos are deleted after 14 days. So if you have a failing test, debug
- it before this period to get access to the video.
+ For maintenance reasons videos are deleted after 14 days. So if you have a failing test, debug
+ it before this period to get access to the video.
Best practice when writing new E2E tests
@@ -154,12 +155,12 @@ Best practice when writing new E2E tests
**Understanding the System and Requirements**
-Before writing tests, a deep understanding of the system and its requirements is crucial.
-This understanding guides determining what needs testing and what defines a successful test.
+Before writing tests, a deep understanding of the system and its requirements is crucial.
+This understanding guides determining what needs testing and what defines a successful test.
The best way to understand is to consolidate the original system`s developer or a person actively working on this
component.
-**Identify Main Test Scenarios**
+**Identify Main Test Scenarios**
Identify what are the main ways the component is supposed to be used. Try
the action with all involved user roles and test as many different inputs as
@@ -209,12 +210,12 @@ and fx the issue, or update the test if the requirements have changed.
**Regularly Review and Refactor Your Tests**
-Tests, like code, can accumulate technical debt. Regular reviews for duplication,
+Tests, like code, can accumulate technical debt. Regular reviews for duplication,
unnecessary complexity, and other issues help maintain tests and enhance reliability.
**Use HTML IDs instead of classes or other attributes**
-When searching for a single element within the DOM of an HTML page, try to use ID selectors as much as possible.
+When searching for a single element within the DOM of an HTML page, try to use ID selectors as much as possible.
They are more reliable since there can only be one element with this ID on one single page according to the HTML
diff --git a/docs/dev/development-process.rst b/docs/dev/development-process.rst
index 4206185d1849..b5e073fb9da4 100644
--- a/docs/dev/development-process.rst
+++ b/docs/dev/development-process.rst
@@ -12,7 +12,8 @@ Naming Conventions for GitHub Pull Requests
1. The first term is a main feature of Artemis and is using code highlighting, e.g. “``Programming exercises``:”.
1. Possible feature tags are: ``Programming exercises``, ``Quiz exercises``, ``Modeling exercises``, ``Text exercises``, ``File upload exercises``, ``Exam mode``,
- ``Grading``, ``Assessment``, ``Communication``, ``Notifications``, ``Team exercises``, ``Lectures``, ``Plagiarism checks``, ``Learning analytics``, ``Adaptive learning``, ``Tutorial groups``.
+ ``Grading``, ``Assessment``, ``Communication``, ``Notifications``, ``Team exercises``, ``Lectures``, ``Plagiarism checks``, ``Learning analytics``,
+ ``Adaptive learning``, ``Tutorial groups``, ``Iris``.
2. If the change is not visible to end users, or it is a pure development or test improvement, we use the term “``Development``:”.
3. Everything else belongs to the ``General`` category.
diff --git a/docs/dev/setup.rst b/docs/dev/setup.rst
index 61a797b9f302..568762fe24df 100644
--- a/docs/dev/setup.rst
+++ b/docs/dev/setup.rst
@@ -709,7 +709,7 @@ HTTP. We need to extend the configuration in the file
------------------------------------------------------------------------------------------------------------------------
Iris/Pyris Service
---------------
+------------------
Iris is an intelligent virtual tutor integrated into the Artemis platform.
It is designed to provide one-on-one programming assistance without human tutors.
@@ -726,7 +726,7 @@ Prerequisites
- Set up a running instance of Pyris_. Refer to the :doc:`../admin/pyris-setup` for more information.
Enable the ``iris`` Spring profile:
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
::
diff --git a/docs/dev/setup/bamboo-bitbucket-jira.rst b/docs/dev/setup/bamboo-bitbucket-jira.rst
index d198da3c60c7..11e7bbb7605d 100644
--- a/docs/dev/setup/bamboo-bitbucket-jira.rst
+++ b/docs/dev/setup/bamboo-bitbucket-jira.rst
@@ -369,8 +369,8 @@ Configure Artemis
server:
port: 8080 # The port of artemis
url: http://172.20.0.1:8080 # needs to be an ip
- // url: http://docker.for.mac.host.internal:8080 # If the above one does not work for mac try this one
- // url: http://host.docker.internal:8080 # If the above one does not work for windows try this one
+ # url: http://docker.for.mac.host.internal:8080 # If the above one does not work for mac try this one
+ # url: http://host.docker.internal:8080 # If the above one does not work for windows try this one
In addition, you have to start Artemis with the profiles ``bamboo``,
``bitbucket`` and ``jira`` so that the correct adapters will be used,
diff --git a/docs/dev/setup/jenkins-gitlab.rst b/docs/dev/setup/jenkins-gitlab.rst
index 9c9e7f395c85..1390aec98bbb 100644
--- a/docs/dev/setup/jenkins-gitlab.rst
+++ b/docs/dev/setup/jenkins-gitlab.rst
@@ -125,11 +125,16 @@ both are set up correctly and follow these steps:
INSERT INTO `artemis`.`jhi_user_authority` (`user_id`, `authority_name`) VALUES (1,"ROLE_ADMIN");
INSERT INTO `artemis`.`jhi_user_authority` (`user_id`, `authority_name`) VALUES (1,"ROLE_USER");
-4. Create a user in Gitlab (``http://your-gitlab-domain/admin/users/new``) and make sure that the username,
-email, and password are the same as the user from the database:
+4. Create a user in Gitlab (``http://your-gitlab-domain/admin/users/new``) and make sure that the username and
+email are the same as the user from the database:
.. figure:: setup/jenkins-gitlab/gitlab_admin_user.png
+5. Edit the new admin user (``http://your-gitlab-domain/admin/users/artemis_admin/edit``) to set the password to the
+same value as in the database:
+
+.. figure:: setup/jenkins-gitlab/gitlab_admin_user_password.png
+
Starting the Artemis server should now succeed.
GitLab
@@ -188,9 +193,9 @@ tokens instead of the predefined ones.
::
- docker compose -f docker/.yml exec gitlab gitlab-rails runner "token = User.find_by_username('root').personal_access_tokens.create(scopes: [:api, :read_user, :read_api, :read_repository, :write_repository, :sudo], name: 'Artemis Admin Token'); token.set_token('artemis-gitlab-token'); token.save!"
+ docker compose -f docker/.yml exec gitlab gitlab-rails runner "token = User.find_by_username('root').personal_access_tokens.create(scopes: ['api', 'read_api', 'read_user', 'read_repository', 'write_repository', 'sudo'], name: 'Artemis Admin Token', expires_at: 365.days.from_now); token.set_token('artemis-gitlab-token'); token.save!"
- | You can also manually create in by navigating to ``http://localhost:8081/-/profile/personal_access_tokens`` and
+ | You can also manually create in by navigating to ``http://localhost:8081/-/profile/personal_access_tokens?name=Artemis+Admin+token&scopes=api,read_api,read_user,read_repository,write_repository,sudo`` and
generate a token with all scopes.
| Copy this token into the ``ADMIN_PERSONAL_ACCESS_TOKEN`` field in the
``docker/gitlab/gitlab-local-setup.sh`` file.
@@ -325,7 +330,7 @@ GitLab Access Token
.. figure:: setup/jenkins-gitlab/gitlab_access_tokens_button.png
:align: center
-10. Create a new token named “Artemis” and give it **all** rights.
+10. Create a new token named “Artemis” and give it rights ``api``, ``read_api``, ``read_user``, ``read_repository``, ``write_repository``, and ``sudo``.
.. figure:: setup/jenkins-gitlab/artemis_gitlab_access_token.png
:align: center
@@ -469,7 +474,7 @@ do either do it manually or using the following command:
::
- docker compose -f docker/.yml exec gitlab gitlab-rails runner "token = User.find_by_username('root').personal_access_tokens.create(scopes: [:api, :read_repository], name: 'Jenkins'); token.set_token('jenkins-gitlab-token'); token.save!"
+ docker compose -f docker/.yml exec gitlab gitlab-rails runner "token = User.find_by_username('root').personal_access_tokens.create(scopes: ['api', 'read_repository'], name: 'Jenkins', expires_at: 365.days.from_now); token.set_token('jenkins-gitlab-token'); token.save!"
@@ -746,7 +751,7 @@ Choose “Download now and install after restart” and checking the
Timestamper Configuration
"""""""""""""""""""""""""
-Go to *Manage Jenkins → Configure System*. There you will find the
+Go to *Manage Jenkins → System Configuration → Configure*. There you will find the
Timestamper configuration, use the following value for both formats:
::
@@ -767,8 +772,8 @@ JUnit formatted results to any URL.
You can download the current release of the plugin
`here `__
(Download the **.hpi** file). Go to the Jenkins plugin page (*Manage
-Jenkins → Manage Plugins*) and install the downloaded file under the
-*Advanced* tab under *Upload Plugin*
+Jenkins → System Configuration → Plugins*) and install the downloaded file under the
+*Advanced settings* tab under *Deploy Plugin*
.. figure:: setup/jenkins-gitlab/jenkins_custom_plugin.png
:align: center
@@ -776,7 +781,7 @@ Jenkins → Manage Plugins*) and install the downloaded file under the
Jenkins Credentials
"""""""""""""""""""
-Go to *Manage Jenkins -> Security -> Manage Credentials → Jenkins → Global credentials* and create the
+Go to *Manage Jenkins → Security → Credentials → Jenkins → Global credentials* and create the
following credentials
GitLab API Token
@@ -798,7 +803,7 @@ GitLab API Token
4. Leave the ID field blank
5. The description is up to you
-3. Go to the Jenkins settings *Manage Jenkins → Configure System*. There
+3. Go to the Jenkins settings *Manage Jenkins → System*. There
you will find the GitLab settings. Fill in the URL of your GitLab
instance and select the just created API token in the credentials
dropdown. After you click on “Test Connection”, everything should
@@ -1013,7 +1018,8 @@ You can either run the builds locally (that means on the machine that hosts Jenk
Configuring local build agents
""""""""""""""""""""""""""""""
-Go to `Manage Jenkins` > `Manage Nodes and Clouds` > `master`
+Go to `Manage Jenkins` → `Nodes` → `Built-In Node` → `Configure`
+
Configure your master node like this (adjust the number of executors, if needed). Make sure to add the docker label.
.. figure:: setup/jenkins-gitlab/jenkins_local_node.png
@@ -1071,26 +1077,28 @@ Add agent in Jenkins:
1. Open Jenkins in your browser (e.g. localhost:8082)
-2. Go to Manage Jenkins -> Manage Credentials -> (global) -> Add Credentials
+2. Go to Manage Jenkins → Credentials → System → Global credentials (unrestricted) → Add Credentials
- Kind: SSH Username with private key
+ - Scope: Global (Jenkins, nodes, items, all child items, etc)
+
- ID: leave blank
- Description: Up to you
- Username: jenkins
- - Private Key: (e.g /root/.ssh/id_rsa)
+ - Private Key: (e.g /root/.ssh/id_rsa)
- - Passphrase: (you can leave it blank if none has been specified)
+ - Passphrase: (you can leave it blank if none has been specified)
.. figure:: setup/jenkins-gitlab/alternative_jenkins_node_credentials.png
:align: center
-3. Go to Manage Jenkins -> Manage Nodes and Clouds -> New Node
+3. Go to Manage Jenkins → Nodes → New Node
- - Node name: Up to you (e.g. Docker)
+ - Node name: Up to you (e.g. Docker agent node)
- Check 'Permanent Agent'
@@ -1223,10 +1231,10 @@ access control in Jenkins.
This enables specific Artemis users to access build plans and execute actions such as triggering a build.
This section explains the changes required in Jenkins in order to set up build plan access control:
-1. Navigate to Manage Jenkins -> Manage Plugins -> Installed and make sure that you have the
+1. Navigate to Manage Jenkins → Plugins → Installed plugins and make sure that you have the
`Matrix Authorization Strategy `__ plugin installed
-2. Navigate to Manage Jenkins -> Configure Global Security and navigate to the "Authorization" section
+2. Navigate to Manage Jenkins → Security and navigate to the "Authorization" section
3. Select the "Project-based Matrix Authorization Strategy" option
diff --git a/docs/dev/setup/jenkins-gitlab/alternative_jenkins_node_credentials.png b/docs/dev/setup/jenkins-gitlab/alternative_jenkins_node_credentials.png
index 4901e0cd2cce..133b81583b92 100644
Binary files a/docs/dev/setup/jenkins-gitlab/alternative_jenkins_node_credentials.png and b/docs/dev/setup/jenkins-gitlab/alternative_jenkins_node_credentials.png differ
diff --git a/docs/dev/setup/jenkins-gitlab/alternative_jenkins_node_setup.png b/docs/dev/setup/jenkins-gitlab/alternative_jenkins_node_setup.png
index 29f5738eafb5..7eca324029fe 100644
Binary files a/docs/dev/setup/jenkins-gitlab/alternative_jenkins_node_setup.png and b/docs/dev/setup/jenkins-gitlab/alternative_jenkins_node_setup.png differ
diff --git a/docs/dev/setup/jenkins-gitlab/artemis_gitlab_access_token.png b/docs/dev/setup/jenkins-gitlab/artemis_gitlab_access_token.png
index 87c382c449e9..f99c0a490422 100644
Binary files a/docs/dev/setup/jenkins-gitlab/artemis_gitlab_access_token.png and b/docs/dev/setup/jenkins-gitlab/artemis_gitlab_access_token.png differ
diff --git a/docs/dev/setup/jenkins-gitlab/gitlab_access_tokens_button.png b/docs/dev/setup/jenkins-gitlab/gitlab_access_tokens_button.png
index e0ccc8e9ffaa..bdd5741b632d 100644
Binary files a/docs/dev/setup/jenkins-gitlab/gitlab_access_tokens_button.png and b/docs/dev/setup/jenkins-gitlab/gitlab_access_tokens_button.png differ
diff --git a/docs/dev/setup/jenkins-gitlab/gitlab_admin_user.png b/docs/dev/setup/jenkins-gitlab/gitlab_admin_user.png
index 7ea3865f73e5..f884f92a04d7 100644
Binary files a/docs/dev/setup/jenkins-gitlab/gitlab_admin_user.png and b/docs/dev/setup/jenkins-gitlab/gitlab_admin_user.png differ
diff --git a/docs/dev/setup/jenkins-gitlab/gitlab_admin_user_password.png b/docs/dev/setup/jenkins-gitlab/gitlab_admin_user_password.png
new file mode 100644
index 000000000000..7a9003dd9584
Binary files /dev/null and b/docs/dev/setup/jenkins-gitlab/gitlab_admin_user_password.png differ
diff --git a/docs/dev/setup/jenkins-gitlab/gitlab_jenkins_token_rights.png b/docs/dev/setup/jenkins-gitlab/gitlab_jenkins_token_rights.png
index 737b827b5880..2e3707db0503 100644
Binary files a/docs/dev/setup/jenkins-gitlab/gitlab_jenkins_token_rights.png and b/docs/dev/setup/jenkins-gitlab/gitlab_jenkins_token_rights.png differ
diff --git a/docs/dev/setup/jenkins-gitlab/gitlab_preferences_button.png b/docs/dev/setup/jenkins-gitlab/gitlab_preferences_button.png
index ba0bae2cf859..f106698f5557 100644
Binary files a/docs/dev/setup/jenkins-gitlab/gitlab_preferences_button.png and b/docs/dev/setup/jenkins-gitlab/gitlab_preferences_button.png differ
diff --git a/docs/dev/setup/jenkins-gitlab/jenkins_authorization_permissions.png b/docs/dev/setup/jenkins-gitlab/jenkins_authorization_permissions.png
index 1dfcd3dfa26d..47b8e19a5be4 100644
Binary files a/docs/dev/setup/jenkins-gitlab/jenkins_authorization_permissions.png and b/docs/dev/setup/jenkins-gitlab/jenkins_authorization_permissions.png differ
diff --git a/docs/dev/setup/jenkins-gitlab/jenkins_custom_plugin.png b/docs/dev/setup/jenkins-gitlab/jenkins_custom_plugin.png
index 6223b74fdc40..aa8cb370489a 100644
Binary files a/docs/dev/setup/jenkins-gitlab/jenkins_custom_plugin.png and b/docs/dev/setup/jenkins-gitlab/jenkins_custom_plugin.png differ
diff --git a/docs/dev/setup/jenkins-gitlab/jenkins_gitlab_configuration.png b/docs/dev/setup/jenkins-gitlab/jenkins_gitlab_configuration.png
index 669cf01eb3f0..ac349efee7b7 100644
Binary files a/docs/dev/setup/jenkins-gitlab/jenkins_gitlab_configuration.png and b/docs/dev/setup/jenkins-gitlab/jenkins_gitlab_configuration.png differ
diff --git a/docs/dev/setup/jenkins-gitlab/jenkins_local_node.png b/docs/dev/setup/jenkins-gitlab/jenkins_local_node.png
index 4c6ceba8831b..8c1a9c20f55a 100644
Binary files a/docs/dev/setup/jenkins-gitlab/jenkins_local_node.png and b/docs/dev/setup/jenkins-gitlab/jenkins_local_node.png differ
diff --git a/docs/dev/setup/jenkins-gitlab/jenkins_master_node.png b/docs/dev/setup/jenkins-gitlab/jenkins_master_node.png
index e39e292e1784..48fe8c638a29 100644
Binary files a/docs/dev/setup/jenkins-gitlab/jenkins_master_node.png and b/docs/dev/setup/jenkins-gitlab/jenkins_master_node.png differ
diff --git a/docs/dev/setup/jenkins-gitlab/jenkins_node.png b/docs/dev/setup/jenkins-gitlab/jenkins_node.png
index 28fabdb3d9c9..77a89ef0b9ce 100644
Binary files a/docs/dev/setup/jenkins-gitlab/jenkins_node.png and b/docs/dev/setup/jenkins-gitlab/jenkins_node.png differ
diff --git a/docs/dev/setup/jenkins-gitlab/jenkins_ssh_credentials.png b/docs/dev/setup/jenkins-gitlab/jenkins_ssh_credentials.png
index f4b27958d527..9e127e1e193e 100644
Binary files a/docs/dev/setup/jenkins-gitlab/jenkins_ssh_credentials.png and b/docs/dev/setup/jenkins-gitlab/jenkins_ssh_credentials.png differ
diff --git a/docs/dev/setup/jenkins-gitlab/jenkins_test_project.png b/docs/dev/setup/jenkins-gitlab/jenkins_test_project.png
index 724e79cd909a..f37c0dd6ff6f 100644
Binary files a/docs/dev/setup/jenkins-gitlab/jenkins_test_project.png and b/docs/dev/setup/jenkins-gitlab/jenkins_test_project.png differ
diff --git a/docs/dev/setup/jenkins-gitlab/timestamper_config.png b/docs/dev/setup/jenkins-gitlab/timestamper_config.png
index 69a3a39e9da4..7fc88479c419 100644
Binary files a/docs/dev/setup/jenkins-gitlab/timestamper_config.png and b/docs/dev/setup/jenkins-gitlab/timestamper_config.png differ
diff --git a/docs/user/exams/instructor/buttons/exam_timeline.png b/docs/user/exams/instructor/buttons/exam_timeline.png
new file mode 100644
index 000000000000..9d1e127b3ce9
Binary files /dev/null and b/docs/user/exams/instructor/buttons/exam_timeline.png differ
diff --git a/docs/user/exams/instructor/exam_timeline_example.png b/docs/user/exams/instructor/exam_timeline_example.png
new file mode 100644
index 000000000000..c356e7c6a1ca
Binary files /dev/null and b/docs/user/exams/instructor/exam_timeline_example.png differ
diff --git a/docs/user/exams/instructors_guide.rst b/docs/user/exams/instructors_guide.rst
index 49666841cee5..f82856586a94 100644
--- a/docs/user/exams/instructors_guide.rst
+++ b/docs/user/exams/instructors_guide.rst
@@ -451,10 +451,23 @@ If you want you can also enable the :ref:`second correction `_.
+Please report any issues on the `GitHub repository `__.
iOS Application
---------------
@@ -107,7 +107,7 @@ The iOS application supports the following features:
-
+
#. View your courses:
#. Register in new courses
#. View courses you have already registered for
@@ -198,7 +198,7 @@ In this screen, users can choose which notification types they want to receive.
Problems
^^^^^^^^
-Please report any issues on the `GitHub repository `_.
+Please report any issues on the `GitHub repository `__.
.. |server-selection-overview-android| image:: native-applications/android/server_selection_overview.png
:width: 300
diff --git a/src/main/java/de/tum/in/www1/artemis/config/CacheConfiguration.java b/src/main/java/de/tum/in/www1/artemis/config/CacheConfiguration.java
index 1629799b1de5..219808bb495b 100644
--- a/src/main/java/de/tum/in/www1/artemis/config/CacheConfiguration.java
+++ b/src/main/java/de/tum/in/www1/artemis/config/CacheConfiguration.java
@@ -1,5 +1,6 @@
package de.tum.in.www1.artemis.config;
+import java.nio.file.Path;
import java.util.Collections;
import javax.annotation.PreDestroy;
@@ -26,6 +27,7 @@
import com.hazelcast.core.HazelcastInstance;
import com.hazelcast.spring.context.SpringManagedContext;
+import de.tum.in.www1.artemis.service.HazelcastPathSerializer;
import de.tum.in.www1.artemis.service.scheduled.cache.quiz.QuizScheduleService;
import tech.jhipster.config.JHipsterProperties;
import tech.jhipster.config.cache.PrefixedKeyGenerator;
@@ -110,6 +112,9 @@ public HazelcastInstance hazelcastInstance(JHipsterProperties jHipsterProperties
// Allows using @SpringAware and therefore Spring Services in distributed tasks
config.setManagedContext(new SpringManagedContext(applicationContext));
config.setClassLoader(applicationContext.getClassLoader());
+
+ config.getSerializationConfig().addSerializerConfig(createPathSerializerConfig());
+
if (registration == null) {
log.warn("No discovery service is set up, Hazelcast cannot create a cluster.");
hazelcastBindOnlyOnInterface("127.0.0.1", config);
@@ -174,6 +179,13 @@ private void hazelcastBindOnlyOnInterface(String hazelcastInterface, Config conf
config.setProperty("hazelcast.socket.client.bind.any", "false");
}
+ private SerializerConfig createPathSerializerConfig() {
+ SerializerConfig serializerConfig = new SerializerConfig();
+ serializerConfig.setTypeClass(Path.class);
+ serializerConfig.setImplementation(new HazelcastPathSerializer());
+ return serializerConfig;
+ }
+
@Autowired(required = false) // ok
public void setGitProperties(GitProperties gitProperties) {
this.gitProperties = gitProperties;
diff --git a/src/main/java/de/tum/in/www1/artemis/config/Constants.java b/src/main/java/de/tum/in/www1/artemis/config/Constants.java
index cc4cc689e911..38af0f2f21f7 100644
--- a/src/main/java/de/tum/in/www1/artemis/config/Constants.java
+++ b/src/main/java/de/tum/in/www1/artemis/config/Constants.java
@@ -263,6 +263,8 @@ public final class Constants {
public static final int HAZELCAST_QUIZ_EXERCISE_CACHE_SERIALIZER_ID = 1;
+ public static final int HAZELCAST_PATH_SERIALIZER_ID = 2;
+
public static final String HAZELCAST_PLAGIARISM_PREFIX = "plagiarism-";
public static final String HAZELCAST_ACTIVE_PLAGIARISM_CHECKS_PER_COURSE_CACHE = HAZELCAST_PLAGIARISM_PREFIX + "active-plagiarism-checks-per-course-cache";
diff --git a/src/main/java/de/tum/in/www1/artemis/config/MetricsBean.java b/src/main/java/de/tum/in/www1/artemis/config/MetricsBean.java
index 878d043d2d93..47e02b7a6233 100644
--- a/src/main/java/de/tum/in/www1/artemis/config/MetricsBean.java
+++ b/src/main/java/de/tum/in/www1/artemis/config/MetricsBean.java
@@ -437,36 +437,32 @@ public void updatePublicArtemisMetrics() {
if (!scheduledMetricsEnabled) {
return;
}
- var startDate = System.currentTimeMillis();
+
+ final long startDate = System.currentTimeMillis();
// The authorization object has to be set because this method is not called by a user but by the scheduler
SecurityUtils.setAuthorizationObject();
- ZonedDateTime now = ZonedDateTime.now();
+ final ZonedDateTime now = ZonedDateTime.now();
- var courses = courseRepository.findAll();
+ final List courses = courseRepository.findAllActiveWithoutTestCourses(now);
// We set the number of students once to prevent multiple queries for the same date
courses.forEach(course -> course.setNumberOfStudents(userRepository.countByGroupsIsContaining(course.getStudentGroupName())));
-
ensureCourseInformationIsSet(courses);
- var activeCourses = courses.stream()
- .filter(course -> (course.getStartDate() == null || course.getStartDate().isBefore(now)) && (course.getEndDate() == null || course.getEndDate().isAfter(now)))
- .toList();
-
- List examsInActiveCourses = new ArrayList<>();
- activeCourses.forEach(course -> examsInActiveCourses.addAll(examRepository.findByCourseId(course.getId())));
+ final List courseIds = courses.stream().mapToLong(Course::getId).boxed().toList();
+ final List examsInActiveCourses = examRepository.findExamsInCourses(courseIds);
// Update multi gauges
- updateStudentsCourseMultiGauge(activeCourses);
+ updateStudentsCourseMultiGauge(courses);
updateStudentsExamMultiGauge(examsInActiveCourses, courses);
updateActiveUserMultiGauge(now);
updateActiveExerciseMultiGauge();
updateExerciseMultiGauge();
// Update normal Gauges
- activeCoursesGauge.set(activeCourses.size());
- coursesGauge.set(courses.size());
+ activeCoursesGauge.set(courses.size());
+ coursesGauge.set((int) courseRepository.count());
activeExamsGauge.set(examRepository.countAllActiveExams(now));
examsGauge.set((int) examRepository.count());
@@ -491,14 +487,18 @@ private void updateStudentsCourseMultiGauge(List activeCourses) {
}
private void updateStudentsExamMultiGauge(List examsInActiveCourses, List courses) {
- studentsExamGauge.register(examsInActiveCourses.stream().map(exam -> MultiGauge.Row.of(Tags.of("examName", exam.getTitle(),
- // The course semester.getCourse() is not populated (the semester property is not set) -> Use course from the courses list, which contains the semester
- "semester", courses.stream().filter(course -> Objects.equals(course.getId(), exam.getCourse().getId())).findAny().map(Course::getSemester).orElse("No semester")),
- studentExamRepository.findByExamId(exam.getId()).size()))
+ studentsExamGauge.register(examsInActiveCourses.stream()
+ .map(exam -> MultiGauge.Row.of(Tags.of("examName", exam.getTitle(), "semester", getExamSemester(courses, exam)),
+ studentExamRepository.findByExamId(exam.getId()).size()))
// A mutable list is required here because otherwise the values can not be updated correctly
.collect(Collectors.toCollection(ArrayList::new)), true);
}
+ private String getExamSemester(final List courses, final Exam exam) {
+ // The exam.getCourse() is not populated (the semester property is not set) -> Use course from the courses list, which contains the semester
+ return courses.stream().filter(course -> Objects.equals(course.getId(), exam.getCourse().getId())).findAny().map(Course::getSemester).orElse("No semester");
+ }
+
private void updateActiveExerciseMultiGauge() {
var results = new ArrayList>();
var result = exerciseRepository.countActiveExercisesGroupByExerciseType(ZonedDateTime.now());
diff --git a/src/main/java/de/tum/in/www1/artemis/domain/Attachment.java b/src/main/java/de/tum/in/www1/artemis/domain/Attachment.java
index 705b61ccd009..153b9d7b2fd7 100644
--- a/src/main/java/de/tum/in/www1/artemis/domain/Attachment.java
+++ b/src/main/java/de/tum/in/www1/artemis/domain/Attachment.java
@@ -15,6 +15,7 @@
import de.tum.in.www1.artemis.config.Constants;
import de.tum.in.www1.artemis.domain.enumeration.AttachmentType;
import de.tum.in.www1.artemis.domain.lecture.AttachmentUnit;
+import de.tum.in.www1.artemis.service.EntityFileService;
import de.tum.in.www1.artemis.service.FilePathService;
import de.tum.in.www1.artemis.service.FileService;
@@ -27,9 +28,15 @@
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public class Attachment extends DomainObject implements Serializable {
+ @Transient
+ private final transient FilePathService filePathService = new FilePathService();
+
@Transient
private final transient FileService fileService = new FileService();
+ @Transient
+ private final transient EntityFileService entityFileService = new EntityFileService(fileService, filePathService);
+
@Transient
private String prevLink;
@@ -97,7 +104,17 @@ else if (attachmentType == AttachmentType.FILE && getAttachmentUnit() != null &&
*/
@PrePersist
public void beforeCreate() {
- handleFileChange();
+ if (link == null) {
+ return;
+ }
+ if (attachmentType == AttachmentType.FILE && getLecture() != null) {
+ Path targetFolder = FilePathService.getLectureAttachmentFilePath().resolve(getLecture().getId().toString());
+ link = entityFileService.moveFileBeforeEntityPersistenceWithIdIfIsTemp(link, targetFolder, true, getLecture().getId());
+ }
+ else if (attachmentType == AttachmentType.FILE && getAttachmentUnit() != null) {
+ Path targetFolder = FilePathService.getAttachmentUnitFilePath().resolve(getAttachmentUnit().getId().toString());
+ link = entityFileService.moveFileBeforeEntityPersistenceWithIdIfIsTemp(link, targetFolder, true, getAttachmentUnit().getId());
+ }
}
/**
@@ -121,19 +138,13 @@ else if (attachmentType == AttachmentType.FILE && link != null && link.contains(
*/
@PreUpdate
public void onUpdate() {
- handleFileChange();
- }
-
- private void handleFileChange() {
if (attachmentType == AttachmentType.FILE && getLecture() != null) {
- // move file and delete old file if necessary
- var targetFolder = Path.of(FilePathService.getLectureAttachmentFilePath(), getLecture().getId().toString()).toString();
- link = fileService.manageFilesForUpdatedFilePath(prevLink, link, targetFolder, getLecture().getId(), true);
+ Path targetFolder = FilePathService.getLectureAttachmentFilePath().resolve(getLecture().getId().toString());
+ link = entityFileService.handlePotentialFileUpdateBeforeEntityPersistence(getLecture().getId(), prevLink, link, targetFolder, true);
}
else if (attachmentType == AttachmentType.FILE && getAttachmentUnit() != null) {
- // move file and delete old file if necessary
- var targetFolder = Path.of(FilePathService.getAttachmentUnitFilePath(), getAttachmentUnit().getId().toString()).toString();
- link = fileService.manageFilesForUpdatedFilePath(prevLink, link, targetFolder, getAttachmentUnit().getId(), true);
+ Path targetFolder = FilePathService.getAttachmentUnitFilePath().resolve(getAttachmentUnit().getId().toString());
+ link = entityFileService.handlePotentialFileUpdateBeforeEntityPersistence(getAttachmentUnit().getId(), prevLink, link, targetFolder, true);
}
}
@@ -143,15 +154,8 @@ else if (attachmentType == AttachmentType.FILE && getAttachmentUnit() != null) {
*/
@PostRemove
public void onDelete() {
- if (attachmentType == AttachmentType.FILE && getLecture() != null) {
- // delete old file if necessary
- var targetFolder = Path.of(FilePathService.getLectureAttachmentFilePath(), getLecture().getId().toString()).toString();
- fileService.manageFilesForUpdatedFilePath(prevLink, null, targetFolder, getLecture().getId(), true);
- }
- else if (attachmentType == AttachmentType.FILE && getAttachmentUnit() != null) {
- // delete old file if necessary
- var targetFolder = Path.of(FilePathService.getAttachmentUnitFilePath(), getAttachmentUnit().getId().toString()).toString();
- fileService.manageFilesForUpdatedFilePath(prevLink, null, targetFolder, getAttachmentUnit().getId(), true);
+ if (prevLink != null && attachmentType == AttachmentType.FILE) {
+ fileService.schedulePathForDeletion(Path.of(prevLink), 0);
}
}
diff --git a/src/main/java/de/tum/in/www1/artemis/domain/Course.java b/src/main/java/de/tum/in/www1/artemis/domain/Course.java
index ed728a567fa1..be230de89c1b 100644
--- a/src/main/java/de/tum/in/www1/artemis/domain/Course.java
+++ b/src/main/java/de/tum/in/www1/artemis/domain/Course.java
@@ -2,6 +2,7 @@
import static de.tum.in.www1.artemis.config.Constants.*;
+import java.nio.file.Path;
import java.time.ZonedDateTime;
import java.util.HashSet;
import java.util.Set;
@@ -30,6 +31,7 @@
import de.tum.in.www1.artemis.domain.tutorialgroups.TutorialGroup;
import de.tum.in.www1.artemis.domain.tutorialgroups.TutorialGroupsConfiguration;
import de.tum.in.www1.artemis.domain.view.QuizView;
+import de.tum.in.www1.artemis.service.EntityFileService;
import de.tum.in.www1.artemis.service.FilePathService;
import de.tum.in.www1.artemis.service.FileService;
import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException;
@@ -48,7 +50,13 @@ public class Course extends DomainObject {
private static final int DEFAULT_COMPLAINT_TEXT_LIMIT = 2000;
@Transient
- private transient FileService fileService = new FileService();
+ private final transient FilePathService filePathService = new FilePathService();
+
+ @Transient
+ private final transient FileService fileService = new FileService();
+
+ @Transient
+ private final transient EntityFileService entityFileService = new EntityFileService(fileService, filePathService);
@Transient
private String prevCourseIcon;
@@ -679,8 +687,9 @@ public void onLoad() {
@PrePersist
public void beforeCreate() {
- // move file if necessary (id at this point will be null, so placeholder will be inserted)
- courseIcon = fileService.manageFilesForUpdatedFilePath(prevCourseIcon, courseIcon, FilePathService.getCourseIconFilePath(), getId());
+ if (courseIcon != null) {
+ courseIcon = entityFileService.moveTempFileBeforeEntityPersistence(courseIcon, FilePathService.getCourseIconFilePath(), false);
+ }
}
@PostPersist
@@ -694,13 +703,14 @@ public void afterCreate() {
@PreUpdate
public void onUpdate() {
// move file and delete old file if necessary
- courseIcon = fileService.manageFilesForUpdatedFilePath(prevCourseIcon, courseIcon, FilePathService.getCourseIconFilePath(), getId());
+ courseIcon = entityFileService.handlePotentialFileUpdateBeforeEntityPersistence(getId(), prevCourseIcon, courseIcon, FilePathService.getCourseIconFilePath(), false);
}
@PostRemove
public void onDelete() {
- // delete old file if necessary
- fileService.manageFilesForUpdatedFilePath(prevCourseIcon, null, FilePathService.getCourseIconFilePath(), getId());
+ if (prevCourseIcon != null) {
+ fileService.schedulePathForDeletion(Path.of(prevCourseIcon), 0);
+ }
}
@Override
diff --git a/src/main/java/de/tum/in/www1/artemis/domain/FileUploadSubmission.java b/src/main/java/de/tum/in/www1/artemis/domain/FileUploadSubmission.java
index 2fee2ecf8998..29bafff4bcb4 100644
--- a/src/main/java/de/tum/in/www1/artemis/domain/FileUploadSubmission.java
+++ b/src/main/java/de/tum/in/www1/artemis/domain/FileUploadSubmission.java
@@ -4,11 +4,8 @@
import javax.persistence.*;
-import org.apache.commons.lang3.math.NumberUtils;
-
import com.fasterxml.jackson.annotation.JsonInclude;
-import de.tum.in.www1.artemis.exception.FilePathParsingException;
import de.tum.in.www1.artemis.service.FilePathService;
import de.tum.in.www1.artemis.service.FileService;
@@ -26,7 +23,7 @@ public String getSubmissionExerciseType() {
}
@Transient
- private transient FileService fileService = new FileService();
+ private final transient FileService fileService = new FileService();
@Column(name = "file_path")
private String filePath;
@@ -37,14 +34,7 @@ public String getSubmissionExerciseType() {
@PostRemove
public void onDelete() {
if (filePath != null) {
- // delete old file if necessary
- final var splittedPath = filePath.split("/");
- final var shouldBeExerciseId = splittedPath.length >= 5 ? splittedPath[4] : null;
- if (!NumberUtils.isCreatable(shouldBeExerciseId)) {
- throw new FilePathParsingException("Unexpected String in upload file path. Should contain the exercise ID: " + shouldBeExerciseId);
- }
- final var exerciseId = Long.parseLong(shouldBeExerciseId);
- fileService.manageFilesForUpdatedFilePath(filePath, null, FileUploadSubmission.buildFilePath(exerciseId, getId()), getId(), true);
+ fileService.schedulePathForDeletion(Path.of(filePath), 0);
}
}
@@ -59,8 +49,8 @@ public String getFilePath() {
* @param submissionId the id of the submission
* @return path where submission for file upload exercise is stored
*/
- public static String buildFilePath(Long exerciseId, Long submissionId) {
- return Path.of(FilePathService.getFileUploadExercisesFilePath(), String.valueOf(exerciseId), String.valueOf(submissionId)).toString();
+ public static Path buildFilePath(Long exerciseId, Long submissionId) {
+ return FilePathService.getFileUploadExercisesFilePath().resolve(exerciseId.toString()).resolve(submissionId.toString());
}
public void setFilePath(String filePath) {
diff --git a/src/main/java/de/tum/in/www1/artemis/domain/exam/ExamUser.java b/src/main/java/de/tum/in/www1/artemis/domain/exam/ExamUser.java
index ee80e01e05c6..e0a18932ed91 100644
--- a/src/main/java/de/tum/in/www1/artemis/domain/exam/ExamUser.java
+++ b/src/main/java/de/tum/in/www1/artemis/domain/exam/ExamUser.java
@@ -1,5 +1,7 @@
package de.tum.in.www1.artemis.domain.exam;
+import java.nio.file.Path;
+
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.JoinColumn;
@@ -18,6 +20,7 @@
import de.tum.in.www1.artemis.config.Constants;
import de.tum.in.www1.artemis.domain.AbstractAuditingEntity;
import de.tum.in.www1.artemis.domain.User;
+import de.tum.in.www1.artemis.service.EntityFileService;
import de.tum.in.www1.artemis.service.FilePathService;
import de.tum.in.www1.artemis.service.FileService;
@@ -26,9 +29,15 @@
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public class ExamUser extends AbstractAuditingEntity {
+ @Transient
+ private final transient FilePathService filePathService = new FilePathService();
+
@Transient
private final transient FileService fileService = new FileService();
+ @Transient
+ private final transient EntityFileService entityFileService = new EntityFileService(fileService, filePathService);
+
@Transient
private String prevSigningImagePath;
@@ -189,11 +198,18 @@ public void onLoad() {
prevStudentImagePath = studentImagePath; // save current path as old path (needed to know old path in onUpdate() and onDelete())
}
+ /**
+ * Will be called before the entity is persisted (saved).
+ * Manages files by taking care of file system changes for this entity.
+ */
@PrePersist
public void beforeCreate() {
- // move file if necessary (id at this point will be null, so placeholder will be inserted)
- signingImagePath = fileService.manageFilesForUpdatedFilePath(prevSigningImagePath, signingImagePath, FilePathService.getExamUserSignatureFilePath(), getId());
- studentImagePath = fileService.manageFilesForUpdatedFilePath(prevStudentImagePath, studentImagePath, FilePathService.getStudentImageFilePath(), getId());
+ if (signingImagePath != null) {
+ signingImagePath = entityFileService.moveTempFileBeforeEntityPersistence(signingImagePath, FilePathService.getExamUserSignatureFilePath(), false);
+ }
+ if (studentImagePath != null) {
+ studentImagePath = entityFileService.moveTempFileBeforeEntityPersistence(studentImagePath, FilePathService.getStudentImageFilePath(), false);
+ }
}
/**
@@ -212,17 +228,29 @@ public void afterCreate() {
}
}
+ /**
+ * Will be called before the entity is flushed.
+ * Manages files by taking care of file system changes for this entity.
+ */
@PreUpdate
public void onUpdate() {
- // move file and delete old file if necessary
- signingImagePath = fileService.manageFilesForUpdatedFilePath(prevSigningImagePath, signingImagePath, FilePathService.getExamUserSignatureFilePath(), getId());
- studentImagePath = fileService.manageFilesForUpdatedFilePath(prevStudentImagePath, studentImagePath, FilePathService.getStudentImageFilePath(), getId());
+ signingImagePath = entityFileService.handlePotentialFileUpdateBeforeEntityPersistence(getId(), prevSigningImagePath, signingImagePath,
+ FilePathService.getExamUserSignatureFilePath(), false);
+ studentImagePath = entityFileService.handlePotentialFileUpdateBeforeEntityPersistence(getId(), prevStudentImagePath, studentImagePath,
+ FilePathService.getStudentImageFilePath(), false);
}
+ /**
+ * Will be called after the entity is removed (deleted).
+ * Manages files by taking care of file system changes for this entity.
+ */
@PostRemove
public void onDelete() {
- // delete old file if necessary
- fileService.manageFilesForUpdatedFilePath(prevSigningImagePath, null, FilePathService.getExamUserSignatureFilePath(), getId());
- fileService.manageFilesForUpdatedFilePath(prevStudentImagePath, null, FilePathService.getStudentImageFilePath(), getId());
+ if (signingImagePath != null) {
+ fileService.schedulePathForDeletion(Path.of(prevSigningImagePath), 0);
+ }
+ if (studentImagePath != null) {
+ fileService.schedulePathForDeletion(Path.of(prevStudentImagePath), 0);
+ }
}
}
diff --git a/src/main/java/de/tum/in/www1/artemis/domain/lecture/Slide.java b/src/main/java/de/tum/in/www1/artemis/domain/lecture/Slide.java
index 5b3f91c68de5..02835ba8797d 100644
--- a/src/main/java/de/tum/in/www1/artemis/domain/lecture/Slide.java
+++ b/src/main/java/de/tum/in/www1/artemis/domain/lecture/Slide.java
@@ -7,7 +7,6 @@
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.PostLoad;
-import javax.persistence.PostPersist;
import javax.persistence.PostRemove;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
@@ -19,6 +18,7 @@
import de.tum.in.www1.artemis.config.Constants;
import de.tum.in.www1.artemis.domain.DomainObject;
+import de.tum.in.www1.artemis.service.EntityFileService;
import de.tum.in.www1.artemis.service.FilePathService;
import de.tum.in.www1.artemis.service.FileService;
@@ -27,9 +27,15 @@
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public class Slide extends DomainObject {
+ @Transient
+ private final transient FilePathService filePathService = new FilePathService();
+
@Transient
private final transient FileService fileService = new FileService();
+ @Transient
+ private final transient EntityFileService entityFileService = new EntityFileService(fileService, filePathService);
+
@Transient
private String prevSlideImagePath;
@@ -80,35 +86,30 @@ public void onLoad() {
prevSlideImagePath = slideImagePath; // save current path as old path (needed to know old path in onUpdate() and onDelete())
}
- @PrePersist
- public void beforeCreate() {
- var targetFolder = Path.of(FilePathService.getAttachmentUnitFilePath(), getAttachmentUnit().getId().toString(), "slide", String.valueOf(getSlideNumber())).toString();
- slideImagePath = fileService.manageFilesForUpdatedFilePath(prevSlideImagePath, slideImagePath, targetFolder, (long) getSlideNumber(), false);
- }
-
/**
- * Will be called after the entity is persisted (saved).
- * Manages files by taking care of file system changes for this entity.
+ * Before persisting the slide, we need to move the file from the temp folder to the actual folder
*/
- @PostPersist
- public void afterCreate() {
- // replace placeholder with actual id if necessary (id is no longer null at this point)
- if (slideImagePath != null && slideImagePath.contains(Constants.FILEPATH_ID_PLACEHOLDER)) {
- slideImagePath = slideImagePath.replace(Constants.FILEPATH_ID_PLACEHOLDER, getAttachmentUnit().getId().toString());
+ @PrePersist
+ public void beforeCreate() {
+ if (slideImagePath == null) {
+ return;
}
+ slideImagePath = entityFileService.moveFileBeforeEntityPersistenceWithIdIfIsTemp(slideImagePath, getTargetFolder(), false, (long) getSlideNumber());
}
@PreUpdate
public void onUpdate() {
- // move file and delete old file if necessary
- var targetFolder = Path.of(FilePathService.getAttachmentUnitFilePath(), getAttachmentUnit().getId().toString(), "slide", String.valueOf(getSlideNumber())).toString();
- slideImagePath = fileService.manageFilesForUpdatedFilePath(prevSlideImagePath, slideImagePath, targetFolder, (long) getSlideNumber(), false);
+ slideImagePath = entityFileService.handlePotentialFileUpdateBeforeEntityPersistence((long) getSlideNumber(), prevSlideImagePath, slideImagePath, getTargetFolder(), false);
}
@PostRemove
public void onDelete() {
- // delete old file if necessary
- var targetFolder = Path.of(FilePathService.getAttachmentUnitFilePath(), getAttachmentUnit().getId().toString(), "slide", String.valueOf(getSlideNumber())).toString();
- fileService.manageFilesForUpdatedFilePath(prevSlideImagePath, null, targetFolder, (long) getSlideNumber(), false);
+ if (prevSlideImagePath != null) {
+ fileService.schedulePathForDeletion(Path.of(prevSlideImagePath), 0);
+ }
+ }
+
+ private Path getTargetFolder() {
+ return FilePathService.getAttachmentUnitFilePath().resolve(Path.of(getAttachmentUnit().getId().toString(), "slide", String.valueOf(getSlideNumber())));
}
}
diff --git a/src/main/java/de/tum/in/www1/artemis/domain/quiz/DragAndDropQuestion.java b/src/main/java/de/tum/in/www1/artemis/domain/quiz/DragAndDropQuestion.java
index cafba8b0bf5e..acd0e7ce53fc 100644
--- a/src/main/java/de/tum/in/www1/artemis/domain/quiz/DragAndDropQuestion.java
+++ b/src/main/java/de/tum/in/www1/artemis/domain/quiz/DragAndDropQuestion.java
@@ -1,5 +1,6 @@
package de.tum.in.www1.artemis.domain.quiz;
+import java.nio.file.Path;
import java.util.*;
import javax.persistence.*;
@@ -14,6 +15,7 @@
import de.tum.in.www1.artemis.config.Constants;
import de.tum.in.www1.artemis.domain.quiz.scoring.*;
import de.tum.in.www1.artemis.domain.view.QuizView;
+import de.tum.in.www1.artemis.service.EntityFileService;
import de.tum.in.www1.artemis.service.FilePathService;
import de.tum.in.www1.artemis.service.FileService;
@@ -26,7 +28,13 @@
public class DragAndDropQuestion extends QuizQuestion {
@Transient
- private transient FileService fileService = new FileService();
+ private final transient FilePathService filePathService = new FilePathService();
+
+ @Transient
+ private final transient FileService fileService = new FileService();
+
+ @Transient
+ private final transient EntityFileService entityFileService = new EntityFileService(fileService, filePathService);
@Transient
private String prevBackgroundFilePath;
@@ -162,8 +170,9 @@ public void onLoad() {
@PrePersist
public void beforeCreate() {
- // move file if necessary (id at this point will be null, so placeholder will be inserted)
- backgroundFilePath = fileService.manageFilesForUpdatedFilePath(prevBackgroundFilePath, backgroundFilePath, FilePathService.getDragAndDropBackgroundFilePath(), getId());
+ if (backgroundFilePath != null) {
+ backgroundFilePath = entityFileService.moveTempFileBeforeEntityPersistence(backgroundFilePath, FilePathService.getDragAndDropBackgroundFilePath(), false);
+ }
}
@PostPersist
@@ -176,14 +185,15 @@ public void afterCreate() {
@PreUpdate
public void onUpdate() {
- // move file and delete old file if necessary
- backgroundFilePath = fileService.manageFilesForUpdatedFilePath(prevBackgroundFilePath, backgroundFilePath, FilePathService.getDragAndDropBackgroundFilePath(), getId());
+ backgroundFilePath = entityFileService.handlePotentialFileUpdateBeforeEntityPersistence(getId(), prevBackgroundFilePath, backgroundFilePath,
+ FilePathService.getDragAndDropBackgroundFilePath(), false);
}
@PostRemove
public void onDelete() {
- // delete old file if necessary
- fileService.manageFilesForUpdatedFilePath(prevBackgroundFilePath, null, FilePathService.getDragAndDropBackgroundFilePath(), getId());
+ if (prevBackgroundFilePath != null) {
+ fileService.schedulePathForDeletion(Path.of(prevBackgroundFilePath), 0);
+ }
}
/**
diff --git a/src/main/java/de/tum/in/www1/artemis/domain/quiz/DragItem.java b/src/main/java/de/tum/in/www1/artemis/domain/quiz/DragItem.java
index cd0559973441..a328ee1704f5 100644
--- a/src/main/java/de/tum/in/www1/artemis/domain/quiz/DragItem.java
+++ b/src/main/java/de/tum/in/www1/artemis/domain/quiz/DragItem.java
@@ -1,5 +1,6 @@
package de.tum.in.www1.artemis.domain.quiz;
+import java.nio.file.Path;
import java.util.HashSet;
import java.util.Set;
@@ -15,6 +16,7 @@
import de.tum.in.www1.artemis.config.Constants;
import de.tum.in.www1.artemis.domain.TempIdObject;
import de.tum.in.www1.artemis.domain.view.QuizView;
+import de.tum.in.www1.artemis.service.EntityFileService;
import de.tum.in.www1.artemis.service.FilePathService;
import de.tum.in.www1.artemis.service.FileService;
@@ -28,7 +30,13 @@
public class DragItem extends TempIdObject implements QuizQuestionComponent {
@Transient
- private transient FileService fileService = new FileService();
+ private final transient FilePathService filePathService = new FilePathService();
+
+ @Transient
+ private final transient FileService fileService = new FileService();
+
+ @Transient
+ private final transient EntityFileService entityFileService = new EntityFileService(fileService, filePathService);
@Transient
private String prevPictureFilePath;
@@ -138,8 +146,9 @@ public void onLoad() {
@PrePersist
public void beforeCreate() {
- // move file if necessary (id at this point will be null, so placeholder will be inserted)
- pictureFilePath = fileService.manageFilesForUpdatedFilePath(prevPictureFilePath, pictureFilePath, FilePathService.getDragItemFilePath(), getId());
+ if (pictureFilePath != null) {
+ pictureFilePath = entityFileService.moveTempFileBeforeEntityPersistence(pictureFilePath, FilePathService.getDragItemFilePath(), false);
+ }
}
@PostPersist
@@ -152,14 +161,15 @@ public void afterCreate() {
@PreUpdate
public void onUpdate() {
- // move file and delete old file if necessary
- pictureFilePath = fileService.manageFilesForUpdatedFilePath(prevPictureFilePath, pictureFilePath, FilePathService.getDragItemFilePath(), getId());
+ pictureFilePath = entityFileService.handlePotentialFileUpdateBeforeEntityPersistence(getId(), prevPictureFilePath, pictureFilePath, FilePathService.getDragItemFilePath(),
+ false);
}
@PostRemove
public void onDelete() {
- // delete old file if necessary
- fileService.manageFilesForUpdatedFilePath(prevPictureFilePath, null, FilePathService.getDragItemFilePath(), getId());
+ if (prevPictureFilePath != null) {
+ fileService.schedulePathForDeletion(Path.of(prevPictureFilePath), 0);
+ }
}
@Override
diff --git a/src/main/java/de/tum/in/www1/artemis/repository/CourseRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/CourseRepository.java
index 301f88ef300d..c7b97619da85 100644
--- a/src/main/java/de/tum/in/www1/artemis/repository/CourseRepository.java
+++ b/src/main/java/de/tum/in/www1/artemis/repository/CourseRepository.java
@@ -81,12 +81,22 @@ SELECT CASE WHEN (count(c) > 0) THEN true ELSE false END
boolean informationSharingConfigurationIsOneOf(@Param("courseId") long courseId, @Param("values") Set values);
@Query("""
- SELECT DISTINCT c FROM Course c
+ SELECT DISTINCT c
+ FROM Course c
WHERE (c.startDate <= :now OR c.startDate IS NULL)
AND (c.endDate >= :now OR c.endDate IS NULL)
""")
List findAllActive(@Param("now") ZonedDateTime now);
+ @Query("""
+ SELECT DISTINCT c
+ FROM Course c
+ WHERE (c.startDate <= :now OR c.startDate IS NULL)
+ AND (c.endDate >= :now OR c.endDate IS NULL)
+ AND c.testCourse = false
+ """)
+ List findAllActiveWithoutTestCourses(@Param("now") ZonedDateTime now);
+
/**
* Note: you should not add exercises or exercises+categories here, because this would make the query too complex and would take significantly longer
*
diff --git a/src/main/java/de/tum/in/www1/artemis/repository/ExamRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/ExamRepository.java
index 5d6ff8d80255..bb02b590b690 100644
--- a/src/main/java/de/tum/in/www1/artemis/repository/ExamRepository.java
+++ b/src/main/java/de/tum/in/www1/artemis/repository/ExamRepository.java
@@ -31,6 +31,13 @@ public interface ExamRepository extends JpaRepository {
List findByCourseId(long courseId);
+ @Query("""
+ SELECT DISTINCT exam
+ FROM Exam exam
+ WHERE exam.course.id IN :courses
+ """)
+ List findExamsInCourses(@Param("courses") Iterable courseId);
+
@Query("""
SELECT DISTINCT ex
FROM Exam ex
diff --git a/src/main/java/de/tum/in/www1/artemis/service/AttachmentUnitService.java b/src/main/java/de/tum/in/www1/artemis/service/AttachmentUnitService.java
index d4a8023c2f55..103045c8daef 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/AttachmentUnitService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/AttachmentUnitService.java
@@ -1,5 +1,6 @@
package de.tum.in.www1.artemis.service;
+import java.net.URI;
import java.time.ZonedDateTime;
import java.util.Objects;
@@ -25,6 +26,8 @@ public class AttachmentUnitService {
private final FileService fileService;
+ private final FilePathService filePathService;
+
private final CacheManager cacheManager;
private final SlideSplitterService slideSplitterService;
@@ -32,10 +35,11 @@ public class AttachmentUnitService {
private final SlideRepository slideRepository;
public AttachmentUnitService(SlideRepository slideRepository, SlideSplitterService slideSplitterService, AttachmentUnitRepository attachmentUnitRepository,
- AttachmentRepository attachmentRepository, FileService fileService, CacheManager cacheManager) {
+ AttachmentRepository attachmentRepository, FileService fileService, FilePathService filePathService, CacheManager cacheManager) {
this.attachmentUnitRepository = attachmentUnitRepository;
this.attachmentRepository = attachmentRepository;
this.fileService = fileService;
+ this.filePathService = filePathService;
this.cacheManager = cacheManager;
this.slideSplitterService = slideSplitterService;
this.slideRepository = slideRepository;
@@ -140,7 +144,7 @@ private void updateAttachment(Attachment existingAttachment, Attachment updateAt
*/
private void handleFile(MultipartFile file, Attachment attachment, boolean keepFilename) {
if (file != null && !file.isEmpty()) {
- String filePath = fileService.handleSaveFile(file, keepFilename, false);
+ String filePath = fileService.handleSaveFile(file, keepFilename, false).toString();
attachment.setLink(filePath);
attachment.setUploadDate(ZonedDateTime.now());
}
@@ -154,7 +158,7 @@ private void handleFile(MultipartFile file, Attachment attachment, boolean keepF
*/
private void evictCache(MultipartFile file, AttachmentUnit attachmentUnit) {
if (file != null && !file.isEmpty()) {
- this.cacheManager.getCache("files").evict(fileService.actualPathForPublicPath(attachmentUnit.getAttachment().getLink()));
+ this.cacheManager.getCache("files").evict(filePathService.actualPathForPublicPath(URI.create(attachmentUnit.getAttachment().getLink())).toString());
}
}
diff --git a/src/main/java/de/tum/in/www1/artemis/service/AuthorizationCheckService.java b/src/main/java/de/tum/in/www1/artemis/service/AuthorizationCheckService.java
index 9e30e5b261c8..e786797c2827 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/AuthorizationCheckService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/AuthorizationCheckService.java
@@ -5,6 +5,7 @@
import java.util.function.Consumer;
import java.util.regex.Pattern;
+import javax.annotation.CheckReturnValue;
import javax.annotation.Nullable;
import javax.validation.constraints.NotNull;
@@ -62,6 +63,7 @@ public AuthorizationCheckService(UserRepository userRepository, CourseRepository
* @param exercise belongs to a course that will be checked for permission rights
* @return true if the currently logged-in user is at least an editor (also if the user is instructor or admin), false otherwise
*/
+ @CheckReturnValue
public boolean isAtLeastEditorForExercise(@NotNull Exercise exercise) {
return isAtLeastEditorInCourse(exercise.getCourseViaExerciseGroupOrCourseMember(), null);
}
@@ -74,6 +76,7 @@ public boolean isAtLeastEditorForExercise(@NotNull Exercise exercise) {
* @param user the user whose permissions should be checked
* @return true if the currently logged-in user is at least an editor, false otherwise
*/
+ @CheckReturnValue
public boolean isAtLeastEditorForExercise(@NotNull Exercise exercise, @Nullable User user) {
return isAtLeastEditorInCourse(exercise.getCourseViaExerciseGroupOrCourseMember(), user);
}
@@ -98,6 +101,7 @@ private void checkIsAtLeastEditorInCourseElseThrow(@NotNull Course course, @Null
* @param user the user whose permissions should be checked
* @return true if the passed user is at least an editor in the course, false otherwise
*/
+ @CheckReturnValue
public boolean isAtLeastEditorInCourse(@NotNull Course course, @Nullable User user) {
user = loadUserIfNeeded(user);
return isEditorInCourse(course, user) || isInstructorInCourse(course, user) || isAdmin(user);
@@ -107,12 +111,13 @@ public boolean isAtLeastEditorInCourse(@NotNull Course course, @Nullable User us
* Given any type of exercise, the method returns if the current user is at least TA for the course the exercise belongs to. If exercise is not present, it will return false,
* because the optional will be empty, and therefore `isPresent()` will return false This is due how `filter` works: If a value is present, apply the provided mapping function
* to it, and if the result is non-null, return an Optional describing the result. Otherwise, return an empty Optional.
- * https://docs.oracle.com/javase/8/docs/api/java/util/Optional.html#filter-java.util.function.Predicate
+ * https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Optional.html#filter(java.util.function.Predicate)
*
* @param exercise the exercise that needs to be checked
* @param The type of the concrete exercise, because Exercise is an abstract class
* @return true if the user is at least a teaching assistant (also if the user is instructor or admin) in the course of the given exercise
*/
+ @CheckReturnValue
public boolean isAtLeastTeachingAssistantForExercise(Optional exercise) {
return exercise.filter(this::isAtLeastTeachingAssistantForExercise).isPresent();
}
@@ -124,6 +129,7 @@ public boolean isAtLeastTeachingAssistantForExercise(Option
* @param exercise belongs to a course that will be checked for permission rights
* @return true if the currently logged-in user is at least a teaching assistant (also if the user is instructor or admin), false otherwise
*/
+ @CheckReturnValue
public boolean isAtLeastTeachingAssistantForExercise(@NotNull Exercise exercise) {
return isAtLeastTeachingAssistantInCourse(exercise.getCourseViaExerciseGroupOrCourseMember(), null);
}
@@ -136,6 +142,7 @@ public boolean isAtLeastTeachingAssistantForExercise(@NotNull Exercise exercise)
* @param user the user whose permissions should be checked
* @return true if the passed user is at least a teaching assistant (also if the user is instructor or admin), false otherwise
*/
+ @CheckReturnValue
public boolean isAtLeastTeachingAssistantForExercise(@NotNull Exercise exercise, @Nullable User user) {
user = loadUserIfNeeded(user);
return isAtLeastTeachingAssistantInCourse(exercise.getCourseViaExerciseGroupOrCourseMember(), user);
@@ -147,6 +154,7 @@ public boolean isAtLeastTeachingAssistantForExercise(@NotNull Exercise exercise,
* @param exercise belongs to a course that will be checked for permission rights
* @return true if the currently logged-in user is at least a student (also if the user is teaching assistant, instructor or admin), false otherwise
*/
+ @CheckReturnValue
public boolean isAtLeastStudentForExercise(@NotNull Exercise exercise) {
return isAtLeastStudentForExercise(exercise, null);
}
@@ -158,6 +166,7 @@ public boolean isAtLeastStudentForExercise(@NotNull Exercise exercise) {
* @param user the user whose permissions should be checked
* @return true if the currently logged-in user is at least a student (also if the user is teaching assistant, instructor or admin), false otherwise
*/
+ @CheckReturnValue
public boolean isAtLeastStudentForExercise(@NotNull Exercise exercise, @Nullable User user) {
user = loadUserIfNeeded(user);
return isStudentInCourse(exercise.getCourseViaExerciseGroupOrCourseMember(), user) || isAtLeastTeachingAssistantForExercise(exercise, user);
@@ -183,6 +192,7 @@ private void checkIsAtLeastTeachingAssistantInCourseElseThrow(@NotNull Course co
* @param user the user whose permissions should be checked
* @return true if the passed user is at least a teaching assistant in the course (also if the user is instructor or admin), false otherwise
*/
+ @CheckReturnValue
public boolean isAtLeastTeachingAssistantInCourse(@NotNull Course course, @Nullable User user) {
user = loadUserIfNeeded(user);
return isTeachingAssistantInCourse(course, user) || isEditorInCourse(course, user) || isInstructorInCourse(course, user) || isAdmin(user);
@@ -220,6 +230,7 @@ private enum EnrollmentAuthorization {
* @return `EnrollmentAuthorization.ALLOWED` if the user is allowed to self enroll in the course,
* or the reason why the user is not allowed to self enroll in the course otherwise
*/
+ @CheckReturnValue
public EnrollmentAuthorization getUserEnrollmentAuthorizationForCourse(User user, Course course) {
if (allowedCourseEnrollmentUsernamePattern != null && !allowedCourseEnrollmentUsernamePattern.matcher(user.getLogin()).matches()) {
return EnrollmentAuthorization.USERNAME_PATTERN;
@@ -248,6 +259,7 @@ public EnrollmentAuthorization getUserEnrollmentAuthorizationForCourse(User user
* @param course The course to which the user wants to self enroll
* @return boolean, true if the user is allowed to self enroll in the course, false otherwise
*/
+ @CheckReturnValue
public boolean isUserAllowedToSelfEnrollInCourse(User user, Course course) {
return EnrollmentAuthorization.ALLOWED.equals(getUserEnrollmentAuthorizationForCourse(user, course));
}
@@ -290,6 +302,7 @@ private enum UnenrollmentAuthorization {
* @return `UnenrollmentAuthorization.ALLOWED` if the user is allowed to self unenroll from the course,
* or the reason why the user is not allowed to self unenroll from the course otherwise
*/
+ @CheckReturnValue
public UnenrollmentAuthorization getUserUnenrollmentAuthorizationForCourse(User user, Course course) {
if (!course.isUnenrollmentEnabled()) {
return UnenrollmentAuthorization.UNENROLLMENT_STATUS;
@@ -326,6 +339,7 @@ public void checkUserAllowedToUnenrollFromCourseElseThrow(User user, Course cour
* @param user the user whose permissions should be checked
* @return true if the passed user is at least a teaching assistant in the course (also if the user is instructor or admin), false otherwise
*/
+ @CheckReturnValue
public boolean isAtLeastStudentInCourse(@NotNull Course course, @Nullable User user) {
user = loadUserIfNeeded(user);
return isStudentInCourse(course, user) || isTeachingAssistantInCourse(course, user) || isEditorInCourse(course, user) || isInstructorInCourse(course, user)
@@ -340,6 +354,7 @@ public boolean isAtLeastStudentInCourse(@NotNull Course course, @Nullable User u
* @param user the user whose permissions should be checked
* @return true if the currently logged-in user is at least an instructor (or admin), false otherwise
*/
+ @CheckReturnValue
public boolean isAtLeastInstructorForExercise(@NotNull Exercise exercise, @Nullable User user) {
return isAtLeastInstructorInCourse(exercise.getCourseViaExerciseGroupOrCourseMember(), user);
}
@@ -350,6 +365,7 @@ public boolean isAtLeastInstructorForExercise(@NotNull Exercise exercise, @Nulla
* @param exercise belongs to a course that will be checked for permission rights
* @return true if the currently logged-in user is at least an instructor (or admin), false otherwise
*/
+ @CheckReturnValue
public boolean isAtLeastInstructorForExercise(@NotNull Exercise exercise) {
return isAtLeastInstructorForExercise(exercise, null);
}
@@ -420,6 +436,7 @@ private void checkIsAtLeastInstructorInCourseElseThrow(@NotNull Course course, @
* @param user the user whose permissions should be checked
* @return true if the passed user is at least instructor in the course (also if the user is admin), false otherwise
*/
+ @CheckReturnValue
public boolean isAtLeastInstructorInCourse(@NotNull Course course, @Nullable User user) {
user = loadUserIfNeeded(user);
return user.getGroups().contains(course.getInstructorGroupName()) || isAdmin(user);
@@ -432,6 +449,7 @@ public boolean isAtLeastInstructorInCourse(@NotNull Course course, @Nullable Use
* @param user the user whose permissions should be checked
* @return true, if user is instructor of this course, otherwise false
*/
+ @CheckReturnValue
public boolean isInstructorInCourse(@NotNull Course course, @Nullable User user) {
user = loadUserIfNeeded(user);
return user.getGroups().contains(course.getInstructorGroupName());
@@ -444,6 +462,7 @@ public boolean isInstructorInCourse(@NotNull Course course, @Nullable User user)
* @param user the user whose permissions should be checked
* @return true, if user is an editor of this course, otherwise false
*/
+ @CheckReturnValue
public boolean isEditorInCourse(@NotNull Course course, @Nullable User user) {
user = loadUserIfNeeded(user);
return user.getGroups().contains(course.getEditorGroupName());
@@ -456,6 +475,7 @@ public boolean isEditorInCourse(@NotNull Course course, @Nullable User user) {
* @param user the user whose permissions should be checked
* @return true, if user is teaching assistant of this course, otherwise false
*/
+ @CheckReturnValue
public boolean isTeachingAssistantInCourse(@NotNull Course course, @Nullable User user) {
user = loadUserIfNeeded(user);
return user.getGroups().contains(course.getTeachingAssistantGroupName());
@@ -468,6 +488,7 @@ public boolean isTeachingAssistantInCourse(@NotNull Course course, @Nullable Use
* @param user the user whose permissions should be checked
* @return true, if user is only student of this course, otherwise false
*/
+ @CheckReturnValue
public boolean isOnlyStudentInCourse(@NotNull Course course, @Nullable User user) {
user = loadUserIfNeeded(user);
return user.getGroups().contains(course.getStudentGroupName()) && !isAtLeastTeachingAssistantInCourse(course, user);
@@ -480,6 +501,7 @@ public boolean isOnlyStudentInCourse(@NotNull Course course, @Nullable User user
* @param user the user whose permissions should be checked
* @return true, if user is student of this course, otherwise false
*/
+ @CheckReturnValue
public boolean isStudentInCourse(@NotNull Course course, @Nullable User user) {
user = loadUserIfNeeded(user);
return user.getGroups().contains(course.getStudentGroupName());
@@ -491,6 +513,7 @@ public boolean isStudentInCourse(@NotNull Course course, @Nullable User user) {
* @param participation the participation that needs to be checked
* @return true, if user is student is owner of this participation, otherwise false
*/
+ @CheckReturnValue
public boolean isOwnerOfParticipation(@NotNull StudentParticipation participation) {
if (participation.getParticipant() == null) {
return false;
@@ -519,6 +542,7 @@ public void isOwnerOfParticipationElseThrow(@NotNull StudentParticipation partic
* @param user the user whose permissions should be checked
* @return true, if user is student is owner of this participation, otherwise false
*/
+ @CheckReturnValue
public boolean isOwnerOfParticipation(@NotNull StudentParticipation participation, @Nullable User user) {
user = loadUserIfNeeded(user);
if (participation.getParticipant() == null) {
@@ -536,6 +560,7 @@ public boolean isOwnerOfParticipation(@NotNull StudentParticipation participatio
* @param user the user whose permissions should be checked
* @return true if user is owner of this team, otherwise false
*/
+ @CheckReturnValue
public boolean isOwnerOfTeam(@NotNull Team team, @NotNull User user) {
return user.equals(team.getOwner());
}
@@ -548,6 +573,7 @@ public boolean isOwnerOfTeam(@NotNull Team team, @NotNull User user) {
* @param user the user whose permissions should be checked
* @return true, if user is student is owner of this team, otherwise false
*/
+ @CheckReturnValue
public boolean isStudentInTeam(@NotNull Course course, String teamShortName, @NotNull User user) {
return userRepository.findAllInTeam(course.getId(), teamShortName).contains(user);
}
@@ -559,6 +585,7 @@ public boolean isStudentInTeam(@NotNull Course course, String teamShortName, @No
* @param user the user whose permissions should be checked
* @return true, if user is allowed to see this exercise, otherwise false
*/
+ @CheckReturnValue
public boolean isAllowedToSeeExercise(@NotNull Exercise exercise, @Nullable User user) {
user = loadUserIfNeeded(user);
if (isAdmin(user)) {
@@ -594,6 +621,7 @@ public void checkIsAllowedToSeeLectureElseThrow(@NotNull Lecture lecture, @Nulla
* @param user the user for which to check permission
* @return true if the user is allowed, false otherwise
*/
+ @CheckReturnValue
public boolean isAllowedToSeeLectureUnit(@NotNull LectureUnit lectureUnit, @Nullable User user) {
user = loadUserIfNeeded(user);
if (isAdmin(user)) {
@@ -611,6 +639,7 @@ public boolean isAllowedToSeeLectureUnit(@NotNull LectureUnit lectureUnit, @Null
*
* @return true, if user is admin, otherwise false
*/
+ @CheckReturnValue
public boolean isAdmin() {
return SecurityUtils.isCurrentUserInRole(Role.ADMIN.getAuthority());
}
@@ -621,6 +650,7 @@ public boolean isAdmin() {
* @param user the user with authorities. If the user is null, the currently logged-in user will be used.
* @return true, if user is admin, otherwise false
*/
+ @CheckReturnValue
public boolean isAdmin(@Nullable User user) {
if (user == null) {
return isAdmin();
@@ -649,6 +679,7 @@ public void checkIsAdminElseThrow(@Nullable User user) {
* @param result the result that should be sent to the client
* @return true if the user is allowed to retrieve the given result, false otherwise
*/
+ @CheckReturnValue
public boolean isUserAllowedToGetResult(Exercise exercise, StudentParticipation participation, Result result) {
return isAtLeastStudentForExercise(exercise) && (isOwnerOfParticipation(participation) || isAtLeastInstructorForExercise(exercise))
&& ExerciseDateService.isAfterAssessmentDueDate(exercise) && result.getAssessor() != null && result.getCompletionDate() != null;
@@ -667,6 +698,7 @@ public boolean isUserAllowedToGetResult(Exercise exercise, StudentParticipation
* @param user - User that requests the result
* @return true if user is allowed to see the result, false otherwise
*/
+ @CheckReturnValue
public boolean isAllowedToGetExamResult(Exercise exercise, StudentParticipation studentParticipation, User user) {
if (this.isAtLeastTeachingAssistantInCourse(exercise.getCourseViaExerciseGroupOrCourseMember(), user) || exercise.isCourseExercise()) {
return true;
@@ -691,6 +723,7 @@ public boolean isAllowedToGetExamResult(Exercise exercise, StudentParticipation
* @param resultId of the result the teaching assistant wants to assess
* @return true if caller is allowed to assess submissions
*/
+ @CheckReturnValue
public boolean isAllowedToAssessExercise(Exercise exercise, User user, Long resultId) {
return this.isAtLeastTeachingAssistantForExercise(exercise, user) && (resultId == null || isAtLeastInstructorForExercise(exercise, user));
}
diff --git a/src/main/java/de/tum/in/www1/artemis/service/DragAndDropQuizAnswerConversionService.java b/src/main/java/de/tum/in/www1/artemis/service/DragAndDropQuizAnswerConversionService.java
index dddd4a12dfca..b78e6f457feb 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/DragAndDropQuizAnswerConversionService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/DragAndDropQuizAnswerConversionService.java
@@ -3,8 +3,8 @@
import java.awt.*;
import java.awt.geom.Line2D;
import java.awt.image.BufferedImage;
-import java.io.File;
import java.io.IOException;
+import java.net.URI;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
@@ -30,14 +30,14 @@
@Service
public class DragAndDropQuizAnswerConversionService {
- private final FileService fileService;
+ private final FilePathService filePathService;
// Drop locations in quiz exercises are relatively positioned and sized using integers in the interval [0, 200]
// this value needs to be consistent with MAX_SIZE_UNIT in quiz-exercise-generator.ts
private static final int MAX_SIZE_UNIT = 200;
- public DragAndDropQuizAnswerConversionService(FileService fileService) {
- this.fileService = fileService;
+ public DragAndDropQuizAnswerConversionService(FilePathService filePathService) {
+ this.filePathService = filePathService;
}
/**
@@ -50,7 +50,7 @@ public DragAndDropQuizAnswerConversionService(FileService fileService) {
public void convertDragAndDropQuizAnswerAndStoreAsPdf(DragAndDropSubmittedAnswer dragAndDropSubmittedAnswer, Path outputDir, boolean showResult) throws IOException {
DragAndDropQuestion question = (DragAndDropQuestion) dragAndDropSubmittedAnswer.getQuizQuestion();
String backgroundFilePath = question.getBackgroundFilePath();
- BufferedImage backgroundImage = ImageIO.read(new File(fileService.actualPathForPublicPath(backgroundFilePath)));
+ BufferedImage backgroundImage = ImageIO.read(filePathService.actualPathForPublicPath(URI.create(backgroundFilePath)).toFile());
generateDragAndDropSubmittedAnswerImage(backgroundImage, dragAndDropSubmittedAnswer, showResult);
Path dndSubmissionPathPdf = outputDir.resolve(
@@ -121,7 +121,7 @@ private void drawTextDragItem(Graphics2D graphics, DropLocationCoordinates dropL
}
private void drawPictureDragItem(Graphics2D graphics, DropLocationCoordinates dropLocationCoordinates, DragAndDropMapping mapping) throws IOException {
- BufferedImage dragItem = ImageIO.read(new File(fileService.actualPathForPublicPath(mapping.getDragItem().getPictureFilePath())));
+ BufferedImage dragItem = ImageIO.read(filePathService.actualPathForPublicPath(URI.create(mapping.getDragItem().getPictureFilePath())).toFile());
Dimension scaledDimForDragItem = getScaledDimension(new Dimension(dragItem.getWidth(), dragItem.getHeight()),
new Dimension(dropLocationCoordinates.width, dropLocationCoordinates.height));
graphics.drawImage(dragItem, dropLocationCoordinates.x, dropLocationCoordinates.y, (int) scaledDimForDragItem.getWidth(), (int) scaledDimForDragItem.getHeight(), null);
diff --git a/src/main/java/de/tum/in/www1/artemis/service/EntityFileService.java b/src/main/java/de/tum/in/www1/artemis/service/EntityFileService.java
new file mode 100644
index 000000000000..49c6a4ff6452
--- /dev/null
+++ b/src/main/java/de/tum/in/www1/artemis/service/EntityFileService.java
@@ -0,0 +1,113 @@
+package de.tum.in.www1.artemis.service;
+
+import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.file.Path;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.FilenameUtils;
+import org.jvnet.hk2.annotations.Service;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Service for handling file operations for entities.
+ */
+@Service
+public class EntityFileService {
+
+ private final Logger log = LoggerFactory.getLogger(EntityFileService.class);
+
+ private final FileService fileService;
+
+ private final FilePathService filePathService;
+
+ public EntityFileService(FileService fileService, FilePathService filePathService) {
+ this.fileService = fileService;
+ this.filePathService = filePathService;
+ }
+
+ /**
+ * Moves a temporary file to the target folder and returns the new path. A placeholder is used as id.
+ * Use {@link #moveFileBeforeEntityPersistenceWithIdIfIsTemp(String, Path, boolean, Long)} to provide an existing id.
+ *
+ * @param entityFilePath the path of the temporary file
+ * @param targetFolder the target folder to move the file to
+ * @param keepFilename whether to keep the filename or generate a new one
+ * @return the new file path as string
+ */
+ @Nonnull
+ public String moveTempFileBeforeEntityPersistence(@Nonnull String entityFilePath, @Nonnull Path targetFolder, boolean keepFilename) {
+ return moveFileBeforeEntityPersistenceWithIdIfIsTemp(entityFilePath, targetFolder, keepFilename, null);
+ }
+
+ /**
+ * Moves a temporary file to the target folder and returns the new path. If the file is not a temporary file, the original path is returned without any changes.
+ *
+ * @param entityFilePath the path of the temporary file
+ * @param targetFolder the target folder to move the file to
+ * @param keepFilename whether to keep the filename or generate a new one
+ * @param entityId the id of the entity that is being persisted, if null, a placeholder gets used
+ * @return the new file path as string
+ */
+ @Nonnull
+ public String moveFileBeforeEntityPersistenceWithIdIfIsTemp(@Nonnull String entityFilePath, @Nonnull Path targetFolder, boolean keepFilename, @Nullable Long entityId) {
+ URI filePath = URI.create(entityFilePath);
+ String filename = Path.of(entityFilePath).getFileName().toString();
+ String extension = FilenameUtils.getExtension(filename);
+ try {
+ Path source = filePathService.actualPathForPublicPathOrThrow(filePath);
+ if (!source.startsWith(FilePathService.getTempFilePath())) {
+ return entityFilePath;
+ }
+ Path target;
+ if (keepFilename) {
+ target = targetFolder.resolve(filename);
+ }
+ else {
+ target = fileService.generateFilePath(fileService.generateTargetFilenameBase(targetFolder), extension, targetFolder);
+ }
+ FileUtils.moveFile(source.toFile(), target.toFile(), REPLACE_EXISTING);
+ URI newPath = filePathService.publicPathForActualPathOrThrow(target, entityId);
+ log.debug("Moved File from {} to {}", source, target);
+ return newPath.toString();
+ }
+ catch (IOException e) {
+ log.error("Error moving file: {}", filePath, e);
+ // fallback return original path
+ return filePath.toString();
+ }
+ }
+
+ /**
+ * Handles a potential file update before entity persistence. It thus does nothing if the optional file doesn't change and otherwise moves a temporary file to the target and/or
+ * deletes the old file.
+ *
+ * @param entityId the id of the entity that is being persisted
+ * @param oldEntityFilePath the old file path of the file that is being updated
+ * @param newEntityFilePath the new file path of the file that is being updated
+ * @param targetFolder the target folder to move the file to
+ * @param keepFilename whether to keep the filename or generate a new one
+ * @return the new file path as string, null if no file exists
+ */
+ @Nullable
+ public String handlePotentialFileUpdateBeforeEntityPersistence(@Nonnull Long entityId, @Nullable String oldEntityFilePath, @Nullable String newEntityFilePath,
+ @Nonnull Path targetFolder, boolean keepFilename) {
+ String resultingPath = newEntityFilePath;
+ if (newEntityFilePath != null) {
+ resultingPath = moveFileBeforeEntityPersistenceWithIdIfIsTemp(newEntityFilePath, targetFolder, keepFilename, entityId);
+ }
+ if (oldEntityFilePath != null && !oldEntityFilePath.equals(newEntityFilePath)) {
+ Path oldFilePath = filePathService.actualPathForPublicPathOrThrow(URI.create(oldEntityFilePath));
+ if (oldFilePath.toFile().exists()) {
+ fileService.schedulePathForDeletion(oldFilePath, 0);
+ }
+ }
+ return resultingPath;
+ }
+}
diff --git a/src/main/java/de/tum/in/www1/artemis/service/FilePathService.java b/src/main/java/de/tum/in/www1/artemis/service/FilePathService.java
index 83b64342d9c4..8281338bb1ee 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/FilePathService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/FilePathService.java
@@ -1,10 +1,17 @@
package de.tum.in.www1.artemis.service;
+import java.net.URI;
import java.nio.file.Path;
+import javax.annotation.Nullable;
+
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
+import de.tum.in.www1.artemis.config.Constants;
+import de.tum.in.www1.artemis.domain.FileUploadSubmission;
+import de.tum.in.www1.artemis.exception.FilePathParsingException;
+
@Service
public class FilePathService {
@@ -20,43 +27,229 @@ public void setFileUploadPathStatic(String fileUploadPath) {
FilePathService.fileUploadPath = fileUploadPath;
}
- public static String getTempFilePath() {
- return Path.of(fileUploadPath, "images", "temp").toString();
+ public static Path getTempFilePath() {
+ return Path.of(fileUploadPath, "images", "temp");
+ }
+
+ public static Path getDragAndDropBackgroundFilePath() {
+ return Path.of(fileUploadPath, "images", "drag-and-drop", "backgrounds");
+ }
+
+ public static Path getDragItemFilePath() {
+ return Path.of(fileUploadPath, "images", "drag-and-drop", "drag-items");
+ }
+
+ public static Path getCourseIconFilePath() {
+ return Path.of(fileUploadPath, "images", "course", "icons");
+ }
+
+ public static Path getExamUserSignatureFilePath() {
+ return Path.of(fileUploadPath, "images", "exam-user", "signatures");
+ }
+
+ public static Path getStudentImageFilePath() {
+ return Path.of(fileUploadPath, "images", "exam-user");
}
- public static String getDragAndDropBackgroundFilePath() {
- return Path.of(fileUploadPath, "images", "drag-and-drop", "backgrounds").toString();
+ public static Path getLectureAttachmentFilePath() {
+ return Path.of(fileUploadPath, "attachments", "lecture");
}
- public static String getDragItemFilePath() {
- return Path.of(fileUploadPath, "images", "drag-and-drop", "drag-items").toString();
+ public static Path getAttachmentUnitFilePath() {
+ return Path.of(fileUploadPath, "attachments", "attachment-unit");
}
- public static String getCourseIconFilePath() {
- return Path.of(fileUploadPath, "images", "course", "icons").toString();
+ public static Path getFileUploadExercisesFilePath() {
+ return Path.of(fileUploadPath, "file-upload-exercises");
}
- public static String getExamUserSignatureFilePath() {
- return Path.of(fileUploadPath, "images", "exam-user", "signatures").toString();
+ public static Path getMarkdownFilePath() {
+ return Path.of(fileUploadPath, "markdown");
}
- public static String getStudentImageFilePath() {
- return Path.of(fileUploadPath, "images", "exam-user").toString();
+ /**
+ * Convert the given public file url to its corresponding local path
+ *
+ * @param publicPath the public file url to convert
+ * @throws FilePathParsingException if the path is unknown
+ * @return the actual path to that file in the local filesystem
+ */
+ public Path actualPathForPublicPathOrThrow(URI publicPath) {
+ Path actualPath = actualPathForPublicPath(publicPath);
+ if (actualPath == null) {
+ // path is unknown => cannot convert
+ throw new FilePathParsingException("Unknown Filepath: " + publicPath);
+ }
+
+ return actualPath;
+ }
+
+ /**
+ * Convert the given public file url to its corresponding local path
+ *
+ * @param publicPath the public file url to convert
+ * @return the actual path to that file in the local filesystem
+ */
+ public Path actualPathForPublicPath(URI publicPath) {
+ // first extract the filename from the url
+ String uriPath = publicPath.getPath();
+ Path path = Path.of(uriPath);
+ String filename = path.getFileName().toString();
+
+ // check for known path to convert
+ if (uriPath.startsWith("/api/files/temp")) {
+ return FilePathService.getTempFilePath().resolve(filename);
+ }
+ if (uriPath.startsWith("/api/files/drag-and-drop/backgrounds")) {
+ return FilePathService.getDragAndDropBackgroundFilePath().resolve(filename);
+ }
+ if (uriPath.startsWith("/api/files/drag-and-drop/drag-items")) {
+ return FilePathService.getDragItemFilePath().resolve(filename);
+ }
+ if (uriPath.startsWith("/api/files/course/icons")) {
+ return FilePathService.getCourseIconFilePath().resolve(filename);
+ }
+ if (uriPath.startsWith("/api/files/exam-user/signatures")) {
+ return FilePathService.getExamUserSignatureFilePath().resolve(filename);
+ }
+ if (uriPath.startsWith("/api/files/exam-user")) {
+ return FilePathService.getStudentImageFilePath().resolve(filename);
+ }
+ if (uriPath.startsWith("/api/files/attachments/lecture")) {
+ String lectureId = path.getName(4).toString();
+ return FilePathService.getLectureAttachmentFilePath().resolve(Path.of(lectureId, filename));
+ }
+ if (uriPath.startsWith("/api/files/attachments/attachment-unit")) {
+ return actualPathForPublicAttachmentUnitFilePath(publicPath, filename);
+ }
+ if (uriPath.startsWith("/api/files/file-upload-exercises")) {
+ return actualPathForPublicFileUploadExercisesFilePath(publicPath, filename);
+ }
+
+ return null;
+ }
+
+ private Path actualPathForPublicAttachmentUnitFilePath(URI publicPath, String filename) {
+ Path path = Path.of(publicPath.getPath());
+ if (!publicPath.toString().contains("/slide")) {
+ String attachmentUnitId = path.getName(4).toString();
+ return FilePathService.getAttachmentUnitFilePath().resolve(Path.of(attachmentUnitId, filename));
+ }
+ try {
+ String attachmentUnitId = path.getName(4).toString();
+ String slideId = path.getName(6).toString();
+ // check if the ids are valid long values
+ Long.parseLong(attachmentUnitId);
+ Long.parseLong(slideId);
+ return FilePathService.getAttachmentUnitFilePath().resolve(Path.of(attachmentUnitId, "slide", slideId, filename));
+ }
+ catch (IllegalArgumentException e) {
+ throw new FilePathParsingException("Public path does not contain correct attachmentUnitId or slideId: " + publicPath, e);
+ }
+ }
+
+ private Path actualPathForPublicFileUploadExercisesFilePath(URI publicPath, String filename) {
+ Path path = Path.of(publicPath.getPath());
+ try {
+ String expectedExerciseId = path.getName(3).toString();
+ String expectedSubmissionId = path.getName(5).toString();
+ Long exerciseId = Long.parseLong(expectedExerciseId);
+ Long submissionId = Long.parseLong(expectedSubmissionId);
+ return FileUploadSubmission.buildFilePath(exerciseId, submissionId).resolve(filename);
+ }
+ catch (IllegalArgumentException e) {
+ throw new FilePathParsingException("Public path does not contain correct exerciseId or submissionId: " + publicPath, e);
+ }
}
- public static String getLectureAttachmentFilePath() {
- return Path.of(fileUploadPath, "attachments", "lecture").toString();
+ /**
+ * Generate the public path for the file at the given path
+ *
+ * @param actualPathString the path to the file in the local filesystem
+ * @param entityId the id of the entity associated with the file
+ * @throws FilePathParsingException if the path is unknown
+ * @return the public file url that can be used by users to access the file from outside
+ */
+ public URI publicPathForActualPathOrThrow(Path actualPathString, @Nullable Long entityId) {
+ URI publicPath = publicPathForActualPath(actualPathString, entityId);
+ if (publicPath == null) {
+ // path is unknown => cannot convert
+ throw new FilePathParsingException("Unknown Filepath: " + actualPathString);
+ }
+
+ return publicPath;
}
- public static String getAttachmentUnitFilePath() {
- return Path.of(fileUploadPath, "attachments", "attachment-unit").toString();
+ /**
+ * Generate the public path for the file at the given path
+ *
+ * @param path the path to the file in the local filesystem
+ * @param entityId the id of the entity associated with the file
+ * @return the public file url that can be used by users to access the file from outside
+ */
+ public URI publicPathForActualPath(Path path, @Nullable Long entityId) {
+ // first extract filename
+ String filename = path.getFileName().toString();
+
+ // generate part for id
+ String id = entityId == null ? Constants.FILEPATH_ID_PLACEHOLDER : entityId.toString();
+ // check for known path to convert
+ if (path.startsWith(FilePathService.getTempFilePath())) {
+ return URI.create(FileService.DEFAULT_FILE_SUBPATH + filename);
+ }
+ if (path.startsWith(FilePathService.getDragAndDropBackgroundFilePath())) {
+ return URI.create("/api/files/drag-and-drop/backgrounds/" + id + "/" + filename);
+ }
+ if (path.startsWith(FilePathService.getDragItemFilePath())) {
+ return URI.create("/api/files/drag-and-drop/drag-items/" + id + "/" + filename);
+ }
+ if (path.startsWith(FilePathService.getCourseIconFilePath())) {
+ return URI.create("/api/files/course/icons/" + id + "/" + filename);
+ }
+ if (path.startsWith(FilePathService.getExamUserSignatureFilePath())) {
+ return URI.create("/api/files/exam-user/signatures/" + id + "/" + filename);
+ }
+ if (path.startsWith(FilePathService.getStudentImageFilePath())) {
+ return URI.create("/api/files/exam-user/" + id + "/" + filename);
+ }
+ if (path.startsWith(FilePathService.getLectureAttachmentFilePath())) {
+ return URI.create("/api/files/attachments/lecture/" + id + "/" + filename);
+ }
+ if (path.startsWith(FilePathService.getAttachmentUnitFilePath())) {
+ return publicPathForActualAttachmentUnitFilePath(path, filename, id);
+ }
+ if (path.startsWith(FilePathService.getFileUploadExercisesFilePath())) {
+ return publicPathForActualFileUploadExercisesFilePath(path, filename, id);
+ }
+
+ return null;
}
- public static String getFileUploadExercisesFilePath() {
- return Path.of(fileUploadPath, "file-upload-exercises").toString();
+ private URI publicPathForActualAttachmentUnitFilePath(Path path, String filename, String id) {
+ if (!path.toString().contains("/slide")) {
+ return URI.create("/api/files/attachments/attachment-unit/" + id + "/" + filename);
+ }
+ try {
+ // The last name is the file name, the one before that is the slide number and the one before that is the attachmentUnitId, in which we are interested
+ // (e.g. uploads/attachments/attachment-unit/941/slide/1/State_pattern_941_Slide_1.png)
+ final String expectedAttachmentUnitId = path.getName(path.getNameCount() - 4).toString();
+ final long attachmentUnitId = Long.parseLong(expectedAttachmentUnitId);
+ return URI.create("/api/files/attachments/attachment-unit/" + attachmentUnitId + "/slide/" + id + "/" + filename);
+ }
+ catch (IllegalArgumentException e) {
+ throw new FilePathParsingException("Unexpected String in upload file path. AttachmentUnit ID should be present here: " + path, e);
+ }
}
- public static String getMarkdownFilePath() {
- return Path.of(fileUploadPath, "markdown").toString();
+ private URI publicPathForActualFileUploadExercisesFilePath(Path path, String filename, String id) {
+ try {
+ // The last name is the file name, the one before that is the submissionId and the one before that is the exerciseId, in which we are interested
+ final var expectedExerciseId = path.getName(path.getNameCount() - 3).toString();
+ final long exerciseId = Long.parseLong(expectedExerciseId);
+ return URI.create("/api/files/file-upload-exercises/" + exerciseId + "/submissions/" + id + "/" + filename);
+ }
+ catch (IllegalArgumentException e) {
+ throw new FilePathParsingException("Unexpected String in upload file path. Exercise ID should be present here: " + path, e);
+ }
}
}
diff --git a/src/main/java/de/tum/in/www1/artemis/service/FileService.java b/src/main/java/de/tum/in/www1/artemis/service/FileService.java
index 629708ecbfbc..ac239e2b645d 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/FileService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/FileService.java
@@ -1,14 +1,13 @@
package de.tum.in.www1.artemis.service;
import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
import java.io.*;
+import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.nio.file.Paths;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.concurrent.*;
@@ -25,7 +24,6 @@
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.filefilter.FileFilterUtils;
import org.apache.commons.io.filefilter.IOFileFilter;
-import org.apache.commons.lang3.math.NumberUtils;
import org.apache.pdfbox.multipdf.PDFMergerUtility;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.tomcat.util.http.fileupload.IOUtils;
@@ -36,15 +34,12 @@
import org.springframework.cache.annotation.Cacheable;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
-import org.springframework.util.FileSystemUtils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.commons.CommonsMultipartFile;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ibm.icu.text.CharsetDetector;
-import de.tum.in.www1.artemis.config.Constants;
-import de.tum.in.www1.artemis.domain.FileUploadSubmission;
import de.tum.in.www1.artemis.exception.FilePathParsingException;
import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException;
import de.tum.in.www1.artemis.web.rest.errors.InternalServerErrorException;
@@ -62,8 +57,8 @@ public class FileService implements DisposableBean {
* A list of common binary file extensions.
* Extensions must be lower-case without leading dots.
*/
- private static final Set binaryFileExtensions = Set.of("png", "jpg", "jpeg", "heic", "gif", "tiff", "psd", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "pages",
- "numbers", "key", "odt", "zip", "rar", "7z", "tar", "iso", "mdb", "sqlite", "exe", "jar", "bin", "so", "dll");
+ private static final Set BINARY_FILE_EXTENSIONS = Set.of("png", "jpg", "jpeg", "heic", "gif", "tiff", "psd", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
+ "pages", "numbers", "key", "odt", "zip", "rar", "7z", "tar", "iso", "mdb", "sqlite", "exe", "jar", "bin", "so", "dll");
/**
* The list of file extensions that are allowed to be uploaded in a Markdown editor.
@@ -118,18 +113,20 @@ public void destroy() {
* @throws IOException if the file can't be accessed.
*/
@Cacheable(value = "files", unless = "#result == null")
- public byte[] getFileForPath(String path) throws IOException {
- File file = new File(path);
- if (file.exists()) {
- return Files.readAllBytes(file.toPath());
- }
- else {
- return null;
+ public byte[] getFileForPath(Path path) throws IOException {
+ if (Files.exists(path)) {
+ return Files.readAllBytes(path);
}
+ return null;
}
+ /**
+ * Evict the cache for the given path
+ *
+ * @param path the path for the file to evict from cache
+ */
@CacheEvict(value = "files", key = "#path")
- public void evictCacheForPath(String path) {
+ public void evictCacheForPath(Path path) {
log.info("Invalidate files cache for {}", path);
// Intentionally blank
}
@@ -152,19 +149,14 @@ public static String sanitizeFilename(String filename) {
* Helper method which handles the file creation for both normal file uploads and for markdown
*
* @param file The file to be uploaded with a maximum file size set in resources/config/application.yml
- * @param keepFileName specifies if original file name should be kept
+ * @param keepFilename specifies if original file name should be kept
* @param markdown boolean which is set to true, when we are uploading a file within the markdown editor
- * @return The path of the file
+ * @return The API path of the file
*/
@NotNull
- public String handleSaveFile(MultipartFile file, boolean keepFileName, boolean markdown) {
+ public URI handleSaveFile(MultipartFile file, boolean keepFilename, boolean markdown) {
// check for file type
- String filename = file.getOriginalFilename();
- if (filename == null) {
- throw new IllegalArgumentException("Filename cannot be null");
- }
-
- filename = sanitizeFilename(filename);
+ String filename = checkAndSanitizeFilename(file.getOriginalFilename());
// Check the allowed file extensions
final String fileExtension = FilenameUtils.getExtension(filename);
@@ -174,35 +166,20 @@ public String handleSaveFile(MultipartFile file, boolean keepFileName, boolean m
throw new BadRequestAlertException("Unsupported file type! Allowed file types: " + String.join(", ", allowedExtensions), "file", null, true);
}
- final String filePath = markdown ? FilePathService.getMarkdownFilePath() : FilePathService.getTempFilePath();
- final String fileNameAddition = markdown ? "Markdown_" : "Temp_";
- final StringBuilder responsePath = new StringBuilder(markdown ? MARKDOWN_FILE_SUBPATH : DEFAULT_FILE_SUBPATH);
-
- String savedFileName = saveFile(filePath, filename, fileNameAddition, fileExtension, keepFileName, file);
- responsePath.append(savedFileName);
-
- return responsePath.toString();
- }
+ final String filenamePrefix = markdown ? "Markdown_" : "Temp_";
+ final Path path = markdown ? FilePathService.getMarkdownFilePath() : FilePathService.getTempFilePath();
- /**
- * Saves a file to the given path
- *
- * @param filePath the path to save the file to excluding the filename
- * @param filename the filename of the file to save including the extension
- * @param fileNameAddition the addition to the filename to make sure it is unique
- * @param fileExtension the extension of the file to save
- * @param keepFileName specifies if original file name should be kept
- * @param file the file to save
- * @return the name of the saved file
- */
- public String saveFile(String filePath, String filename, String fileNameAddition, String fileExtension, boolean keepFileName, MultipartFile file) {
+ Path filePath;
+ if (keepFilename) {
+ filePath = path.resolve(filename);
+ }
+ else {
+ filePath = generateFilePath(filenamePrefix, fileExtension, path);
+ }
try {
- File newFile = createNewFile(filePath, filename, fileNameAddition, fileExtension, keepFileName);
-
- // copy contents of uploaded file into newly created file
- Files.copy(file.getInputStream(), newFile.toPath(), REPLACE_EXISTING);
+ FileUtils.copyToFile(file.getInputStream(), filePath.toFile());
- return newFile.toPath().getFileName().toString();
+ return generateResponsePath(filePath, markdown);
}
catch (IOException e) {
log.error("Could not save file {}", filename, e);
@@ -210,381 +187,103 @@ public String saveFile(String filePath, String filename, String fileNameAddition
}
}
- /**
- * Creates a new file from given contents
- *
- * @param filePath the path to save the file to excluding the filename
- * @param filename the filename of the file to save
- * @param fileNameAddition the addition to the filename to make sure it is unique
- * @param fileExtension the extension of the file to save
- * @param keepFileName specifies if original file name should be kept
- * @return the created file
- */
- private File createNewFile(String filePath, String filename, String fileNameAddition, String fileExtension, boolean keepFileName) throws IOException {
- try {
- Files.createDirectories(Paths.get(filePath));
- }
- catch (IOException e) {
- log.error("Could not create directory: {}", filePath);
- throw e;
- }
- boolean fileCreated;
- File newFile;
- String newFilename = filename;
- do {
- if (!keepFileName) {
- // append a timestamp and some randomness to the filename to avoid conflicts
- newFilename = fileNameAddition + ZonedDateTime.now().toString().substring(0, 23).replaceAll("[:.]", "-") + "_" + UUID.randomUUID().toString().substring(0, 8) + "."
- + fileExtension;
- }
-
- newFile = Path.of(filePath, newFilename).toFile();
- if (keepFileName && newFile.exists()) {
- Files.delete(newFile.toPath());
- }
- fileCreated = newFile.createNewFile();
+ private String checkAndSanitizeFilename(String filename) {
+ if (filename == null) {
+ throw new IllegalArgumentException("Filename cannot be null");
}
- while (!fileCreated);
- return newFile;
+ return sanitizeFilename(filename);
}
/**
- * Copies an existing file (if not a temporary file) to a target location. Returns the public path for the resulting file.
+ * Generates the API path getting returned to the client
*
- * @param oldFilePath the old file path
- * @param targetFolder the folder that a file should be copied to
- * @param entityId id of the entity this file belongs to (needed to generate public path). If this is null, a placeholder will be inserted where the id would be
- * @return the resulting public path
+ * @param filePath the file system path of the file
+ * @param markdown boolean which is set to true, when we are uploading a file in the Markdown format
+ * @return the API path of the file
*/
- public String copyExistingFileToTarget(String oldFilePath, String targetFolder, Long entityId) {
- if (oldFilePath != null && !oldFilePath.contains("files/temp")) {
- try {
- Path source = Path.of(actualPathForPublicPathOrThrow(oldFilePath));
- File targetFile = generateTargetFile(oldFilePath, targetFolder, false);
- Path target = targetFile.toPath();
- Files.copy(source, target, REPLACE_EXISTING);
- String newFilePath = publicPathForActualPathOrThrow(target.toString(), entityId);
- log.debug("Moved File from {} to {}", source, target);
- return newFilePath;
- }
- catch (IOException e) {
- log.error("Error moving file: {}", oldFilePath);
- }
+ private URI generateResponsePath(Path filePath, boolean markdown) {
+ String filename = filePath.getFileName().toString();
+ if (markdown) {
+ return URI.create(MARKDOWN_FILE_SUBPATH).resolve(filename);
}
- return oldFilePath;
+ return URI.create(DEFAULT_FILE_SUBPATH).resolve(filename);
}
/**
- * Takes care of any changes that have to be made to the filesystem (deleting old files, moving temporary files into their proper location) and returns the public path for the
- * resulting file (as it might have been moved from newFilePath to another path)
+ * Generates the path for the file to be saved to with a random file name based on the parameters.
*
- * @param oldFilePath the old file path (this file will be deleted if not null and different from newFilePath)
- * @param newFilePath the new file path (this file will be moved into its proper location, if it was a temporary file)
- * @param targetFolder the folder that a temporary file should be moved to
- * @param entityId id of the entity this file belongs to (needed to generate
- * public path). If this is null, a placeholder will be inserted where the id would be
- * @return the resulting public path (is identical to newFilePath, if file didn't need to be moved)
+ * @param filenamePrefix the prefix of the filename
+ * @param fileExtension the extension of the file
+ * @param folder the folder to save the file to
+ * @return the path to save the file to
*/
- public String manageFilesForUpdatedFilePath(String oldFilePath, String newFilePath, String targetFolder, Long entityId) {
- return manageFilesForUpdatedFilePath(oldFilePath, newFilePath, targetFolder, entityId, false);
+ public Path generateFilePath(String filenamePrefix, String fileExtension, Path folder) {
+ // append a timestamp and some randomness to the filename to avoid conflicts
+ String generatedFilename = filenamePrefix + ZonedDateTime.now().toString().substring(0, 23).replaceAll("[:.]", "-") + "_" + UUID.randomUUID().toString().substring(0, 8)
+ + "." + fileExtension;
+ return folder.resolve(generatedFilename);
}
/**
- * Takes care of any changes that have to be made to the filesystem (deleting old files, moving temporary files into their proper location) and returns the public path for the
- * resulting file (as it might have been moved from newFilePath to another path)
+ * Copies an existing non-temporary file to a target location.
*
- * @param oldFilePath the old file path (this file will be deleted if not null and different from newFilePath)
- * @param newFilePath the new file path (this file will be moved into its proper location, if it was a temporary file)
- * @param targetFolder the folder that a temporary file should be moved to
- * @param entityId id of the entity this file belongs to (needed to generate public path). If this is null, a placeholder will be inserted where the id would be
- * @param keepFileName flag for determining if the current filename should be kept.
- * @return the resulting public path (is identical to newFilePath, if file didn't need to be moved)
+ * @param oldFilePath the old file path
+ * @param targetFolder the folder that a file should be copied to
+ * @return the resulting file path or null on error
*/
- public String manageFilesForUpdatedFilePath(String oldFilePath, String newFilePath, String targetFolder, Long entityId, Boolean keepFileName) {
- if (oldFilePath != null) {
- if (oldFilePath.equals(newFilePath)) {
- // Do nothing
- return newFilePath;
- }
- else {
- // delete old file
- log.debug("Delete old file {}", oldFilePath);
- try {
- File oldFile = new File(actualPathForPublicPathOrThrow(oldFilePath));
-
- if (!FileSystemUtils.deleteRecursively(oldFile)) {
- log.warn("FileService.manageFilesForUpdatedFilePath: Could not delete old file: {}", oldFile);
- }
- else {
- log.debug("Deleted Orphaned File: {}", oldFile);
- }
- }
- catch (Exception ex) {
- log.warn("FileService.manageFilesForUpdatedFilePath: Could not delete old file '{}' due to exception {}", oldFilePath, ex.getMessage());
- }
- }
- }
-
- return moveFileIfTemporaryAndReturnPath(newFilePath, targetFolder, entityId, keepFileName);
- }
-
- private String moveFileIfTemporaryAndReturnPath(String path, String targetFolder, Long entityId, Boolean keepFileName) {
- if (path != null && path.contains("files/temp")) {
- // rename and move file
+ public Path copyExistingFileToTarget(Path oldFilePath, Path targetFolder) {
+ if (oldFilePath != null && !pathContains(oldFilePath, Path.of(("files/temp")))) {
+ String filename = oldFilePath.getFileName().toString();
try {
- Path source = Path.of(actualPathForPublicPathOrThrow(path));
- File targetFile = generateTargetFile(path, targetFolder, keepFileName);
- Path target = targetFile.toPath();
- Files.move(source, target, REPLACE_EXISTING);
- log.debug("Moved File from {} to {}", source, target);
- return publicPathForActualPathOrThrow(target.toString(), entityId);
+ Path target = generateFilePath(generateTargetFilenameBase(targetFolder), FilenameUtils.getExtension(filename), targetFolder);
+ FileUtils.copyFile(oldFilePath.toFile(), target.toFile());
+ log.debug("Moved File from {} to {}", oldFilePath, target);
+ return target;
}
catch (IOException e) {
- log.error("Error moving file: {}", path);
+ log.error("Error moving file: {}", oldFilePath, e);
}
}
- return path;
- }
-
- /**
- * Convert the given public file url to its corresponding local path
- *
- * @param publicPath the public file url to convert
- * @return the actual path to that file in the local filesystem
- */
- public String actualPathForPublicPathOrThrow(String publicPath) {
- String actualPath = actualPathForPublicPath(publicPath);
- if (actualPath == null) {
- // path is unknown => cannot convert
- throw new FilePathParsingException("Unknown Filepath: " + publicPath);
- }
-
- return actualPath;
- }
-
- /**
- * Convert the given public file url to its corresponding local path
- *
- * @param publicPath the public file url to convert
- * @return the actual path to that file in the local filesystem
- */
- public String actualPathForPublicPath(String publicPath) {
- // first extract the filename from the url
- String filename = publicPath.substring(publicPath.lastIndexOf("/") + 1);
-
- // check for known path to convert
- if (publicPath.contains("files/temp")) {
- return Path.of(FilePathService.getTempFilePath(), filename).toString();
- }
- if (publicPath.contains("files/drag-and-drop/backgrounds")) {
- return Path.of(FilePathService.getDragAndDropBackgroundFilePath(), filename).toString();
- }
- if (publicPath.contains("files/drag-and-drop/drag-items")) {
- return Path.of(FilePathService.getDragItemFilePath(), filename).toString();
- }
- if (publicPath.contains("files/course/icons")) {
- return Path.of(FilePathService.getCourseIconFilePath(), filename).toString();
- }
- if (publicPath.contains("files/exam-user")) {
- return Path.of(FilePathService.getStudentImageFilePath(), filename).toString();
- }
- if (publicPath.contains("files/exam-user/signatures")) {
- return Path.of(FilePathService.getExamUserSignatureFilePath(), filename).toString();
- }
- if (publicPath.contains("files/attachments/lecture")) {
- String lectureId = publicPath.replace(filename, "").replace("/api/files/attachments/lecture/", "");
- return Path.of(FilePathService.getLectureAttachmentFilePath(), lectureId, filename).toString();
- }
- if (publicPath.contains("files/attachments/attachment-unit")) {
- if (!publicPath.contains("/slide")) {
- String attachmentUnitId = publicPath.replace(filename, "").replace("/api/files/attachments/attachment-unit/", "");
- return Path.of(FilePathService.getAttachmentUnitFilePath(), attachmentUnitId, filename).toString();
- }
- final var slideSubPath = publicPath.replace(filename, "").replace("/api/files/attachments/attachment-unit/", "").split("/");
- final var shouldBeAttachmentUnitId = slideSubPath[0];
- final var shouldBeSlideId = slideSubPath.length >= 3 ? slideSubPath[2] : null;
- if (!NumberUtils.isCreatable(shouldBeAttachmentUnitId) || !NumberUtils.isCreatable(shouldBeSlideId)) {
- throw new FilePathParsingException("Public path does not contain correct shouldBeAttachmentUnitId or shouldBeSlideId: " + publicPath);
- }
- final var attachmentUnitId = Long.parseLong(shouldBeAttachmentUnitId);
- final var slideId = Long.parseLong(shouldBeSlideId);
- return Path.of(FilePathService.getAttachmentUnitFilePath(), String.valueOf(attachmentUnitId), "slide", String.valueOf(slideId), filename).toString();
- }
- if (publicPath.contains("files/file-upload-exercises")) {
- final var uploadSubPath = publicPath.replace(filename, "").replace("/api/files/file-upload-exercises/", "").split("/");
- final var shouldBeExerciseId = uploadSubPath[0];
- final var shouldBeSubmissionId = uploadSubPath.length >= 3 ? uploadSubPath[2] : null;
- if (!NumberUtils.isCreatable(shouldBeExerciseId) || !NumberUtils.isCreatable(shouldBeSubmissionId)) {
- throw new FilePathParsingException("Public path does not contain correct exerciseId or submissionId: " + publicPath);
- }
- final var exerciseId = Long.parseLong(shouldBeExerciseId);
- final var submissionId = Long.parseLong(shouldBeSubmissionId);
- return Path.of(FileUploadSubmission.buildFilePath(exerciseId, submissionId), filename).toString();
- }
-
- return null;
- }
-
- /**
- * Generate the public path for the file at the given path
- *
- * @param actualPathString the path to the file in the local filesystem
- * @param entityId the id of the entity associated with the file
- * @return the public file url that can be used by users to access the file from outside
- * @throws FilePathParsingException if the path is unknown
- */
- public String publicPathForActualPathOrThrow(String actualPathString, @Nullable Long entityId) {
- String publicPath = publicPathForActualPath(actualPathString, entityId);
- if (publicPath == null) {
- // path is unknown => cannot convert
- throw new FilePathParsingException("Unknown Filepath: " + actualPathString);
- }
-
- return publicPath;
- }
-
- /**
- * Generate the public path for the file at the given path
- *
- * @param actualPathString the path to the file in the local filesystem
- * @param entityId the id of the entity associated with the file
- * @return the public file url that can be used by users to access the file from outside
- */
- public String publicPathForActualPath(String actualPathString, @Nullable Long entityId) {
- // first extract filename
- Path actualPath = Path.of(actualPathString);
- String filename = actualPath.getFileName().toString();
-
- // generate part for id
- String id = entityId == null ? Constants.FILEPATH_ID_PLACEHOLDER : entityId.toString();
- // check for known path to convert
- if (actualPathString.contains(FilePathService.getTempFilePath())) {
- return DEFAULT_FILE_SUBPATH + filename;
- }
- if (actualPathString.contains(FilePathService.getDragAndDropBackgroundFilePath())) {
- return "/api/files/drag-and-drop/backgrounds/" + id + "/" + filename;
- }
- if (actualPathString.contains(FilePathService.getDragItemFilePath())) {
- return "/api/files/drag-and-drop/drag-items/" + id + "/" + filename;
- }
- if (actualPathString.contains(FilePathService.getCourseIconFilePath())) {
- return "/api/files/course/icons/" + id + "/" + filename;
- }
- if (actualPathString.contains(FilePathService.getExamUserSignatureFilePath())) {
- return "/api/files/exam-user/signatures/" + id + "/" + filename;
- }
- if (actualPathString.contains(FilePathService.getStudentImageFilePath())) {
- return "/api/files/exam-user/" + id + "/" + filename;
- }
- if (actualPathString.contains(FilePathService.getLectureAttachmentFilePath())) {
- return "/api/files/attachments/lecture/" + id + "/" + filename;
- }
- if (actualPathString.contains(FilePathService.getAttachmentUnitFilePath())) {
- if (!actualPathString.contains("/slide")) {
- return "/api/files/attachments/attachment-unit/" + id + "/" + filename;
- }
- try {
- // The last name is the file name, the one before that is the slide number and the one before that is the attachmentUnitId, in which we are interested
- // (e.g. uploads/attachments/attachment-unit/941/slide/1/State_pattern_941_Slide_1.png)
- final var shouldBeAttachmentUnitId = actualPath.getName(actualPath.getNameCount() - 4).toString();
- final long attachmentUnitId = Long.parseLong(shouldBeAttachmentUnitId);
- return "/api/files/attachments/attachment-unit/" + attachmentUnitId + "/slide/" + id + "/" + filename;
- }
- catch (IllegalArgumentException e) {
- throw new FilePathParsingException("Unexpected String in upload file path. AttachmentUnit ID should be present here: " + actualPathString);
- }
- }
- if (actualPathString.contains(FilePathService.getFileUploadExercisesFilePath())) {
- final long exerciseId;
- try {
- // The last name is the file name, the one before that is the submissionId and the one before that is the exerciseId, in which we are interested
- final var shouldBeExerciseId = actualPath.getName(actualPath.getNameCount() - 3).toString();
- exerciseId = Long.parseLong(shouldBeExerciseId);
- }
- catch (IllegalArgumentException e) {
- throw new FilePathParsingException("Unexpected String in upload file path. Exercise ID should be present here: " + actualPathString);
- }
- return "/api/files/file-upload-exercises/" + exerciseId + "/submissions/" + id + "/" + filename;
- }
-
return null;
}
/**
- * Creates a new file at the given location with a proper filename consisting of type, timestamp and a random part
+ * Generates a prefix for the filename based on the target folder
*
- * @param originalFilename the original filename of the file (needed to determine the file type)
- * @param targetFolder the folder where the new file should be created
- * @param keepFileName if true, the original filename will be kept, otherwise a new filename will be generated
- * @return the newly created file
- * @throws IOException if the file can't be generated.
+ * @param targetFolder the target folder
+ * @return the prefix ending with an underscore character as a separator
*/
- public File generateTargetFile(String originalFilename, String targetFolder, Boolean keepFileName) throws IOException {
- // determine the base for the filename
- String filenameBase = "Unspecified_";
+ public String generateTargetFilenameBase(Path targetFolder) {
if (targetFolder.equals(FilePathService.getDragAndDropBackgroundFilePath())) {
- filenameBase = "DragAndDropBackground_";
+ return "DragAndDropBackground_";
}
if (targetFolder.equals(FilePathService.getDragItemFilePath())) {
- filenameBase = "DragItem_";
+ return "DragItem_";
}
if (targetFolder.equals(FilePathService.getCourseIconFilePath())) {
- filenameBase = "CourseIcon_";
+ return "CourseIcon_";
}
if (targetFolder.equals(FilePathService.getExamUserSignatureFilePath())) {
- filenameBase = "ExamUserSignature_";
+ return "ExamUserSignature_";
}
if (targetFolder.equals(FilePathService.getStudentImageFilePath())) {
- filenameBase = "ExamUserImage_";
+ return "ExamUserImage_";
}
- if (targetFolder.contains(FilePathService.getLectureAttachmentFilePath())) {
- filenameBase = "LectureAttachment_";
+ if (pathContains(targetFolder, FilePathService.getLectureAttachmentFilePath())) {
+ return "LectureAttachment_";
}
- if (targetFolder.contains(FilePathService.getAttachmentUnitFilePath())) {
- filenameBase = "AttachmentUnit_";
+ if (pathContains(targetFolder, FilePathService.getAttachmentUnitFilePath())) {
+ return "AttachmentUnit_";
}
- if (targetFolder.contains(FilePathService.getAttachmentUnitFilePath()) && targetFolder.contains("/slide")) {
- filenameBase = "AttachmentUnitSlide_";
+ if (pathContains(targetFolder, FilePathService.getAttachmentUnitFilePath()) && pathContains(targetFolder, Path.of("/slide"))) {
+ return "AttachmentUnitSlide_";
}
+ return "Unspecified_";
+ }
- // extract the file extension
- String fileExtension = FilenameUtils.getExtension(originalFilename);
-
- // create folder if necessary
- File folder = new File(targetFolder);
- if (!folder.exists()) {
- if (!folder.mkdirs()) {
- log.error("Could not create directory: {}", targetFolder);
- throw new IOException("Could not create directory: " + targetFolder);
- }
- }
-
- // create the file (retry if filename already exists)
- boolean fileCreated;
- File newFile;
- String filename = originalFilename;
- do {
- if (keepFileName) {
- if (filename.contains(DEFAULT_FILE_SUBPATH)) {
- filename = filename.replace(DEFAULT_FILE_SUBPATH, "");
- }
- }
- else {
- filename = filenameBase + ZonedDateTime.now().toString().substring(0, 23).replaceAll("[:.]", "-") + "_" + UUID.randomUUID().toString().substring(0, 8) + "."
- + fileExtension;
- }
- var path = Path.of(targetFolder, filename).toString();
-
- newFile = new File(path);
- if (keepFileName && newFile.exists()) {
- Files.delete(newFile.toPath());
- }
- fileCreated = newFile.createNewFile();
- }
- while (!fileCreated);
-
- return newFile;
+ private boolean pathContains(Path path, Path subPath) {
+ return path.normalize().toString().contains(subPath.normalize().toString());
}
/**
@@ -616,21 +315,20 @@ public void copyResources(final Resource[] resources, final Path prefix, final P
* @throws IOException If the copying operation fails.
*/
public void copyResource(final Resource resource, final Path prefix, final Path targetDirectory, final boolean keepParentDirectories) throws IOException {
- final Path targetPath = getTargetPath(resource, prefix, targetDirectory, keepParentDirectories);
+ final Path targetPath = generateTargetPath(resource, prefix, targetDirectory, keepParentDirectories);
if (isIgnoredDirectory(targetPath)) {
return;
}
- Files.createDirectories(targetPath.getParent());
- Files.copy(resource.getInputStream(), targetPath, REPLACE_EXISTING);
+ FileUtils.copyToFile(resource.getInputStream(), targetPath.toFile());
if (targetPath.endsWith("gradlew")) {
targetPath.toFile().setExecutable(true);
}
}
- private Path getTargetPath(final Resource resource, final Path prefix, final Path targetDirectory, final boolean keepParentDirectory) throws IOException {
+ private Path generateTargetPath(final Resource resource, final Path prefix, final Path targetDirectory, final boolean keepParentDirectory) throws IOException {
final Path filePath;
if (resource.isFile()) {
filePath = resource.getFile().toPath();
@@ -640,15 +338,15 @@ private Path getTargetPath(final Resource resource, final Path prefix, final Pat
filePath = Path.of(url);
}
- final Path targetPath = getTargetPath(filePath, prefix, targetDirectory, keepParentDirectory);
+ final Path targetPath = generateTargetPath(filePath, prefix, targetDirectory, keepParentDirectory);
return applyFilenameReplacements(targetPath);
}
/**
- * Determines the target file path which a resource should be copied to.
+ * Generates the target file path which a resource should be copied to.
*
* Searches for {@code prefix} in the {@code source} and removes all path elements including and up to the prefix.
- * The target file path is then determined by resolving this trimmed path against the target directory.
+ * The target file path is then determined by resolving the remaining path against the target directory.
*
* @param source The path where the resource is copied from.
* @param prefix The prefix that should be trimmed from the source path.
@@ -656,7 +354,7 @@ private Path getTargetPath(final Resource resource, final Path prefix, final Pat
* @param keepParentDirectory Keep directories in the path between prefix and filename.
* @return The target path where the resource should be copied to.
*/
- private Path getTargetPath(final Path source, final Path prefix, final Path targetDirectory, final boolean keepParentDirectory) {
+ private Path generateTargetPath(final Path source, final Path prefix, final Path targetDirectory, final boolean keepParentDirectory) {
if (!keepParentDirectory) {
return targetDirectory.resolve(source.getFileName());
}
@@ -666,14 +364,14 @@ private Path getTargetPath(final Path source, final Path prefix, final Path targ
final int prefixStartIdx = Collections.indexOfSubList(sourcePathElements, prefixPathElements);
- if (prefixStartIdx >= 0) {
- final int startIdx = prefixStartIdx + prefixPathElements.size();
- final Path relativeSource = source.subpath(startIdx, sourcePathElements.size());
- return targetDirectory.resolve(relativeSource);
- }
- else {
+ if (prefixStartIdx < 0) {
return targetDirectory.resolve(source);
}
+
+ final int startIdx = prefixStartIdx + prefixPathElements.size();
+ final Path relativeSource = source.subpath(startIdx, sourcePathElements.size());
+
+ return targetDirectory.resolve(relativeSource);
}
private List getPathElements(final Path path) {
@@ -717,14 +415,14 @@ private boolean isIgnoredDirectory(final Path filePath) {
* @param targetDirectoryPath the path of the folder where the renamed folder should be located
* @throws IOException if the directory could not be renamed.
*/
- public void renameDirectory(String oldDirectoryPath, String targetDirectoryPath) throws IOException {
- File oldDirectory = new File(oldDirectoryPath);
+ public void renameDirectory(Path oldDirectoryPath, Path targetDirectoryPath) throws IOException {
+ File oldDirectory = oldDirectoryPath.toFile();
if (!oldDirectory.exists()) {
log.error("Directory {} should be renamed but does not exist.", oldDirectoryPath);
throw new RuntimeException("Directory " + oldDirectoryPath + " should be renamed but does not exist.");
}
- File targetDirectory = new File(targetDirectoryPath);
+ File targetDirectory = targetDirectoryPath.toFile();
FileUtils.moveDirectory(oldDirectory, targetDirectory);
}
@@ -735,9 +433,9 @@ public void renameDirectory(String oldDirectoryPath, String targetDirectoryPath)
* @param filePath of file to look for replaceable sections in.
* @param sections of structure String (section name) / Boolean (keep content in section or remove it).
*/
- public void replacePlaceholderSections(String filePath, Map sections) {
+ public void replacePlaceholderSections(Path filePath, Map sections) {
Map patternBooleanMap = sections.entrySet().stream().collect(Collectors.toMap(e -> Pattern.compile(".*%" + e.getKey() + ".*%.*"), Map.Entry::getValue));
- File file = new File(filePath);
+ File file = filePath.toFile();
File tempFile = new File(filePath + "_temp");
if (!file.exists()) {
throw new FilePathParsingException("File " + filePath + " should be updated but does not exist.");
@@ -791,7 +489,7 @@ public void replacePlaceholderSections(String filePath, Map sec
// Accessing already opened files will cause an exception on Windows machines, therefore close the streams
try {
Files.delete(file.toPath());
- FileUtils.moveFile(tempFile, new File(filePath));
+ FileUtils.moveFile(tempFile, filePath.toFile());
}
catch (IOException ex) {
throw new RuntimeException("Error encountered when reading File " + filePath + ".", ex);
@@ -806,17 +504,17 @@ public void replacePlaceholderSections(String filePath, Map sec
* @param replacementString the string that should be used to replace the target
* @throws IOException if an issue occurs on file access for the replacement of the variables.
*/
- public void replaceVariablesInDirectoryName(String startPath, String targetString, String replacementString) throws IOException {
+ public void replaceVariablesInDirectoryName(Path startPath, String targetString, String replacementString) throws IOException {
log.debug("Replacing {} with {} in directory {}", targetString, replacementString, startPath);
- File directory = new File(startPath);
+ File directory = startPath.toFile();
if (!directory.exists() || !directory.isDirectory()) {
throw new RuntimeException("Directory " + startPath + " should be replaced but does not exist.");
}
-
- if (startPath.contains(targetString)) {
+ String pathString = startPath.toString();
+ if (pathString.contains(targetString)) {
log.debug("Target String found, replacing..");
- String targetPath = startPath.replace(targetString, replacementString);
- renameDirectory(startPath, targetPath);
+ String targetPath = pathString.replace(targetString, replacementString);
+ renameDirectory(startPath, Path.of(targetPath));
directory = new File(targetPath);
}
@@ -825,7 +523,7 @@ public void replaceVariablesInDirectoryName(String startPath, String targetStrin
if (subDirectories != null) {
for (String subDirectory : subDirectories) {
- replaceVariablesInDirectoryName(Path.of(directory.getAbsolutePath(), subDirectory).toString(), targetString, replacementString);
+ replaceVariablesInDirectoryName(directory.toPath().toAbsolutePath().resolve(subDirectory), targetString, replacementString);
}
}
}
@@ -838,18 +536,20 @@ public void replaceVariablesInDirectoryName(String startPath, String targetStrin
* @param replacementString the string that should be used to replace the target
* @throws IOException if an issue occurs on file access for the replacement of the variables.
*/
- public void replaceVariablesInFileName(String startPath, String targetString, String replacementString) throws IOException {
+ public void replaceVariablesInFilename(Path startPath, String targetString, String replacementString) throws IOException {
log.debug("Replacing {} with {} in directory {}", targetString, replacementString, startPath);
- File directory = new File(startPath);
+ File directory = startPath.toFile();
if (!directory.exists() || !directory.isDirectory()) {
throw new FileNotFoundException("Files in the directory " + startPath + " should be replaced but it does not exist.");
}
// rename all files in the file tree
- try (var files = Files.find(Path.of(startPath), Integer.MAX_VALUE, (filePath, fileAttr) -> fileAttr.isRegularFile() && filePath.toString().contains(targetString))) {
+ try (var files = Files.find(startPath, Integer.MAX_VALUE, (filePath, fileAttr) -> fileAttr.isRegularFile() && filePath.toString().contains(targetString))) {
files.forEach(filePath -> {
try {
- Files.move(filePath, Path.of(filePath.toString().replace(targetString, replacementString)));
+ // We expect the strings to be clean already, so the filename shouldn't change. If it does, we are on the safe side with the sanitation.
+ String cleanFileName = sanitizeFilename(filePath.toString().replace(targetString, replacementString));
+ FileUtils.moveFile(filePath.toFile(), new File(cleanFileName));
}
catch (IOException e) {
throw new RuntimeException("File " + filePath + " should be replaced but does not exist.");
@@ -892,7 +592,7 @@ public void replaceVariablesInFileRecursive(Path startPath, Map
// filter out files that should be ignored
files = Arrays.stream(files).filter(Predicate.not(filesToIgnore::contains)).toArray(String[]::new);
for (String file : files) {
- replaceVariablesInFile(Path.of(directory.getAbsolutePath(), file), replacements);
+ replaceVariablesInFile(directory.toPath().toAbsolutePath().resolve(file), replacements);
}
}
@@ -904,7 +604,7 @@ public void replaceVariablesInFileRecursive(Path startPath, Map
// ignore files in the '.git' folder
continue;
}
- replaceVariablesInFileRecursive(Path.of(directory.getAbsolutePath(), subDirectory), replacements, filesToIgnore);
+ replaceVariablesInFileRecursive(directory.toPath().toAbsolutePath().resolve(subDirectory), replacements, filesToIgnore);
}
}
}
@@ -918,7 +618,6 @@ public void replaceVariablesInFileRecursive(Path startPath, Map
*/
public void replaceVariablesInFile(Path filePath, Map replacements) {
log.debug("Replacing {} in file {}", replacements, filePath);
-
if (isBinaryFile(filePath)) {
// do not try to read binary files with 'readString'
return;
@@ -931,7 +630,7 @@ public void replaceVariablesInFile(Path filePath, Map replacemen
for (Map.Entry replacement : replacements.entrySet()) {
fileContent = fileContent.replace(replacement.getKey(), replacement.getValue());
}
- Files.writeString(filePath, fileContent, UTF_8);
+ FileUtils.writeStringToFile(filePath.toFile(), fileContent, UTF_8);
}
catch (IOException ex) {
log.warn("Exception {} occurred when trying to replace {} in (binary) file {}", ex.getMessage(), replacements, filePath);
@@ -948,7 +647,7 @@ public void replaceVariablesInFile(Path filePath, Map replacemen
*/
private static boolean isBinaryFile(Path filePath) {
final String fileExtension = FilenameUtils.getExtension(filePath.getFileName().toString());
- return binaryFileExtensions.stream().anyMatch(fileExtension::equalsIgnoreCase);
+ return BINARY_FILE_EXTENSIONS.stream().anyMatch(fileExtension::equalsIgnoreCase);
}
/**
@@ -972,7 +671,7 @@ public void normalizeLineEndingsDirectory(Path startPath) throws IOException {
Collection files = FileUtils.listFiles(directory, FileFilterUtils.trueFileFilter(), directoryFileFilter);
for (File file : files) {
- normalizeLineEndings(file.toPath());
+ normalizeLineEndings(file.toPath().toAbsolutePath());
}
}
@@ -993,7 +692,7 @@ public void normalizeLineEndings(Path filePath) throws IOException {
// https://stackoverflow.com/questions/3776923/how-can-i-normalize-the-eol-character-in-java
String fileContent = Files.readString(filePath, UTF_8);
fileContent = fileContent.replaceAll("\\r\\n?", "\n");
- Files.writeString(filePath, fileContent, UTF_8);
+ FileUtils.writeStringToFile(filePath.toFile(), fileContent, UTF_8);
}
/**
@@ -1004,7 +703,7 @@ public void normalizeLineEndings(Path filePath) throws IOException {
* @param startPath the path where the start directory is located
* @throws IOException if an issue occurs on file access when converting to UTF-8.
*/
- public void convertToUTF8Directory(Path startPath) throws IOException {
+ public void convertFilesInDirectoryToUtf8(Path startPath) throws IOException {
log.debug("Converting files in directory {} to UTF-8", startPath);
File directory = startPath.toFile();
if (!directory.exists() || !directory.isDirectory()) {
@@ -1037,7 +736,7 @@ public void convertToUTF8(Path filePath) throws IOException {
String fileContent = new String(contentArray, charset);
- Files.writeString(filePath, fileContent, UTF_8);
+ FileUtils.writeStringToFile(filePath.toFile(), fileContent, UTF_8);
}
/**
@@ -1054,12 +753,15 @@ public Charset detectCharset(byte[] contentArray) {
}
/**
- * Schedule the deletion of the given path with a given delay
+ * Schedule the deletion of the given nullsafe path with a given delay
*
* @param path The path that should be deleted
* @param delayInMinutes The delay in minutes after which the path should be deleted
*/
- public void scheduleForDeletion(Path path, long delayInMinutes) {
+ public void schedulePathForDeletion(@Nullable Path path, long delayInMinutes) {
+ if (path == null) {
+ return;
+ }
ScheduledFuture> future = executor.schedule(() -> {
try {
if (Files.exists(path)) {
@@ -1077,12 +779,15 @@ public void scheduleForDeletion(Path path, long delayInMinutes) {
}
/**
- * Schedule the recursive deletion of the given directory with a given delay.
+ * Schedule the recursive deletion of the given nullsafe directory with a given delay.
*
* @param path The path to the directory that should be deleted
* @param delayInMinutes The delay in minutes after which the path should be deleted
*/
- public void scheduleForDirectoryDeletion(Path path, long delayInMinutes) {
+ public void scheduleDirectoryPathForRecursiveDeletion(@Nullable Path path, long delayInMinutes) {
+ if (path == null) {
+ return;
+ }
ScheduledFuture> future = executor.schedule(() -> {
try {
if (Files.exists(path) && Files.isDirectory(path)) {
@@ -1106,7 +811,7 @@ public void scheduleForDirectoryDeletion(Path path, long delayInMinutes) {
* @param path the original path, e.g. /opt/artemis/repos-download
* @return the unique path, e.g. /opt/artemis/repos-download/1609579674868
*/
- private Path getUniquePath(Path path) {
+ public Path getUniqueSubfolderPath(Path path) {
var uniquePath = path.resolve(String.valueOf(System.currentTimeMillis()));
if (!Files.exists(uniquePath) && Files.isDirectory(path)) {
try {
@@ -1120,15 +825,16 @@ private Path getUniquePath(Path path) {
}
/**
- * create a unique path by appending a folder named with the current milliseconds (e.g. 1609579674868) of the system and schedules it for deletion
+ * create a unique path by appending a folder named with the current milliseconds (e.g. 1609579674868) of the system and schedules it for deletion.
+ * See {@link #getUniqueSubfolderPath(Path)} for more information.
*
* @param path the original path, e.g. /opt/artemis/repos-download
* @param deleteDelayInMinutes the delay in minutes after which the path should be deleted
* @return the unique path, e.g. /opt/artemis/repos-download/1609579674868
*/
- public Path getTemporaryUniquePath(Path path, long deleteDelayInMinutes) {
- var temporaryPath = getUniquePath(path);
- scheduleForDirectoryDeletion(temporaryPath, deleteDelayInMinutes);
+ public Path getTemporaryUniqueSubfolderPath(Path path, long deleteDelayInMinutes) {
+ var temporaryPath = getUniqueSubfolderPath(path);
+ scheduleDirectoryPathForRecursiveDeletion(temporaryPath, deleteDelayInMinutes);
return temporaryPath;
}
@@ -1143,7 +849,7 @@ public Path getTemporaryUniquePath(Path path, long deleteDelayInMinutes) {
*/
public Path getTemporaryUniquePathWithoutPathCreation(Path path, long deleteDelayInMinutes) {
var temporaryPath = path.resolve(String.valueOf(System.currentTimeMillis()));
- scheduleForDirectoryDeletion(temporaryPath, deleteDelayInMinutes);
+ scheduleDirectoryPathForRecursiveDeletion(temporaryPath, deleteDelayInMinutes);
return temporaryPath;
}
@@ -1179,10 +885,10 @@ public Path writeObjectToJsonFile(Object object, ObjectMapper objectMapper, Path
* Merge the PDF files located in the given paths.
*
* @param paths list of paths to merge
- * @param mergedPdfFileName title of merged pdf file
+ * @param mergedPdfFilename title of merged pdf file
* @return byte array of the merged file
*/
- public Optional mergePdfFiles(List paths, String mergedPdfFileName) {
+ public Optional mergePdfFiles(List paths, String mergedPdfFilename) {
if (paths == null || paths.isEmpty()) {
return Optional.empty();
}
@@ -1198,7 +904,7 @@ public Optional mergePdfFiles(List paths, String mergedPdfFileNa
}
PDDocumentInformation pdDocumentInformation = new PDDocumentInformation();
- pdDocumentInformation.setTitle(mergedPdfFileName);
+ pdDocumentInformation.setTitle(mergedPdfFilename);
pdfMerger.setDestinationDocumentInformation(pdDocumentInformation);
pdfMerger.setDestinationStream(outputStream);
@@ -1232,18 +938,18 @@ public void deleteFiles(List filePaths) {
/**
* Convert byte[] to MultipartFile by using CommonsMultipartFile
*
- * @param fileName file name to set file name
+ * @param filename file name to set file name
* @param extension extension of the file (e.g .pdf or .png)
* @param streamByteArray byte array to save to the temp file
* @return multipartFile wrapper for the file stored on disk with a sanitized name
*/
- public MultipartFile convertByteArrayToMultipart(String fileName, String extension, byte[] streamByteArray) {
+ public MultipartFile convertByteArrayToMultipart(String filename, String extension, byte[] streamByteArray) {
try {
- String cleanFileName = sanitizeFilename(fileName);
- Path tempPath = Path.of(FilePathService.getTempFilePath(), cleanFileName + extension);
- Files.write(tempPath, streamByteArray);
+ String cleanFilename = sanitizeFilename(filename);
+ Path tempPath = FilePathService.getTempFilePath().resolve(cleanFilename + extension);
+ FileUtils.writeByteArrayToFile(tempPath.toFile(), streamByteArray);
File outputFile = tempPath.toFile();
- FileItem fileItem = new DiskFileItem(cleanFileName, Files.probeContentType(tempPath), false, outputFile.getName(), (int) outputFile.length(),
+ FileItem fileItem = new DiskFileItem(cleanFilename, Files.probeContentType(tempPath), false, outputFile.getName(), (int) outputFile.length(),
outputFile.getParentFile());
try (InputStream input = new FileInputStream(outputFile); OutputStream fileItemOutputStream = fileItem.getOutputStream()) {
@@ -1252,7 +958,7 @@ public MultipartFile convertByteArrayToMultipart(String fileName, String extensi
return new CommonsMultipartFile(fileItem);
}
catch (IOException e) {
- log.error("Could not convert file {}.", fileName, e);
+ log.error("Could not convert file {}.", filename, e);
throw new InternalServerErrorException("Error while converting byte[] to MultipartFile by using CommonsMultipartFile");
}
}
diff --git a/src/main/java/de/tum/in/www1/artemis/service/FileUploadSubmissionService.java b/src/main/java/de/tum/in/www1/artemis/service/FileUploadSubmissionService.java
index 538676be7d47..df4454fddcc9 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/FileUploadSubmissionService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/FileUploadSubmissionService.java
@@ -2,14 +2,15 @@
import java.io.File;
import java.io.IOException;
+import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.nio.file.StandardCopyOption;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Optional;
import org.apache.commons.codec.digest.DigestUtils;
+import org.apache.commons.io.FileUtils;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@@ -35,17 +36,21 @@ public class FileUploadSubmissionService extends SubmissionService {
private final FileService fileService;
+ private final FilePathService filePathService;
+
private final ExerciseDateService exerciseDateService;
public FileUploadSubmissionService(FileUploadSubmissionRepository fileUploadSubmissionRepository, SubmissionRepository submissionRepository, ResultRepository resultRepository,
ParticipationService participationService, UserRepository userRepository, StudentParticipationRepository studentParticipationRepository, FileService fileService,
AuthorizationCheckService authCheckService, FeedbackRepository feedbackRepository, ExamDateService examDateService, ExerciseDateService exerciseDateService,
- CourseRepository courseRepository, ParticipationRepository participationRepository, ComplaintRepository complaintRepository, FeedbackService feedbackService) {
+ CourseRepository courseRepository, ParticipationRepository participationRepository, ComplaintRepository complaintRepository, FeedbackService feedbackService,
+ FilePathService filePathService) {
super(submissionRepository, userRepository, authCheckService, resultRepository, studentParticipationRepository, participationService, feedbackRepository, examDateService,
exerciseDateService, courseRepository, participationRepository, complaintRepository, feedbackService);
this.fileUploadSubmissionRepository = fileUploadSubmissionRepository;
this.fileService = fileService;
this.exerciseDateService = exerciseDateService;
+ this.filePathService = filePathService;
}
/**
@@ -115,7 +120,7 @@ public Optional getRandomFileUploadSubmissionEligibleForNe
public FileUploadSubmission save(FileUploadSubmission fileUploadSubmission, MultipartFile file, StudentParticipation participation, FileUploadExercise exercise)
throws IOException, EmptyFileException {
- String newFilePath = storeFile(fileUploadSubmission, participation, file, exercise);
+ URI newFilePath = storeFile(fileUploadSubmission, participation, file, exercise);
// update submission properties
fileUploadSubmission.setSubmissionDate(ZonedDateTime.now());
@@ -132,14 +137,14 @@ public FileUploadSubmission save(FileUploadSubmission fileUploadSubmission, Mult
// Note: we save before the new file path is set to potentially remove the old file on the file system
fileUploadSubmission = fileUploadSubmissionRepository.save(fileUploadSubmission);
- fileUploadSubmission.setFilePath(newFilePath);
+ fileUploadSubmission.setFilePath(newFilePath.toString());
// Note: we save again so that the new file is stored on the file system
fileUploadSubmission = fileUploadSubmissionRepository.save(fileUploadSubmission);
return fileUploadSubmission;
}
- private String storeFile(FileUploadSubmission fileUploadSubmission, StudentParticipation participation, MultipartFile file, FileUploadExercise exercise)
+ private URI storeFile(FileUploadSubmission fileUploadSubmission, StudentParticipation participation, MultipartFile file, FileUploadExercise exercise)
throws EmptyFileException, IOException {
if (file.isEmpty()) {
throw new EmptyFileException(file.getOriginalFilename());
@@ -150,11 +155,11 @@ private String storeFile(FileUploadSubmission fileUploadSubmission, StudentParti
if (fileUploadSubmission.getId() == null) {
fileUploadSubmission = fileUploadSubmissionRepository.save(fileUploadSubmission);
}
- final String savePath = saveFileForSubmission(file, fileUploadSubmission, exercise);
- final String newFilePath = fileService.publicPathForActualPath(savePath, fileUploadSubmission.getId());
+ final Path savePath = saveFileForSubmission(file, fileUploadSubmission, exercise);
+ final URI newFilePath = filePathService.publicPathForActualPath(savePath, fileUploadSubmission.getId());
// We need to ensure that we can access the store file and the stored file is the same as was passed to us in the request
- final var storedFileHash = DigestUtils.md5Hex(Files.newInputStream(Path.of(savePath)));
+ final var storedFileHash = DigestUtils.md5Hex(Files.newInputStream(savePath));
if (!multipartFileHash.equals(storedFileHash)) {
throw new IOException("The file " + file.getName() + "could not be stored");
}
@@ -163,7 +168,7 @@ private String storeFile(FileUploadSubmission fileUploadSubmission, StudentParti
Optional previousFileUploadSubmission = participation.findLatestSubmission();
previousFileUploadSubmission.filter(previousSubmission -> previousSubmission.getFilePath() != null).ifPresent(previousSubmission -> {
- final String oldFilePath = previousSubmission.getFilePath();
+ final URI oldFilePath = URI.create(previousSubmission.getFilePath());
// check if we already had a file associated with this submission
if (!oldFilePath.equals(newFilePath)) { // different name
// IMPORTANT: only delete the file when it has changed the name
@@ -177,7 +182,7 @@ private String storeFile(FileUploadSubmission fileUploadSubmission, StudentParti
return newFilePath;
}
- private String saveFileForSubmission(final MultipartFile file, final Submission submission, FileUploadExercise exercise) throws IOException {
+ private Path saveFileForSubmission(final MultipartFile file, final Submission submission, FileUploadExercise exercise) throws IOException {
final var exerciseId = exercise.getId();
final var submissionId = submission.getId();
var filename = file.getOriginalFilename();
@@ -186,22 +191,17 @@ private String saveFileForSubmission(final MultipartFile file, final Submission
var components = filename.split("\\\\");
filename = components[components.length - 1];
}
- // replace all illegal characters with ascii characters \w means A-Za-z0-9 to avoid problems during download later on
- filename = filename.replaceAll("[^\\w.-]", "");
+ filename = FileService.sanitizeFilename(filename);
// if the filename is now too short, we prepend "file"
// this prevents potential problems when users call their file e.g. ßßß.pdf
if (filename.length() < 5) {
filename = "file" + filename;
}
- final var dirPath = FileUploadSubmission.buildFilePath(exerciseId, submissionId);
- final var filePath = Path.of(dirPath, filename).toString();
- final var savedFile = new File(filePath);
- final var dir = new File(dirPath);
+ final Path dirPath = FileUploadSubmission.buildFilePath(exerciseId, submissionId);
+ final Path filePath = dirPath.resolve(filename);
+ final File savedFile = filePath.toFile();
- if (!dir.exists()) {
- dir.mkdirs();
- }
- Files.copy(file.getInputStream(), savedFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ FileUtils.copyToFile(file.getInputStream(), savedFile);
return filePath;
}
diff --git a/src/main/java/de/tum/in/www1/artemis/service/HazelcastPathSerializer.java b/src/main/java/de/tum/in/www1/artemis/service/HazelcastPathSerializer.java
new file mode 100644
index 000000000000..23a55c67d48d
--- /dev/null
+++ b/src/main/java/de/tum/in/www1/artemis/service/HazelcastPathSerializer.java
@@ -0,0 +1,28 @@
+package de.tum.in.www1.artemis.service;
+
+import static de.tum.in.www1.artemis.config.Constants.HAZELCAST_PATH_SERIALIZER_ID;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+
+import com.hazelcast.nio.serialization.ByteArraySerializer;
+
+public class HazelcastPathSerializer implements ByteArraySerializer {
+
+ @Override
+ public byte[] write(Path path) throws IOException {
+ return path.toString().getBytes(StandardCharsets.UTF_8);
+ }
+
+ @Override
+ public Path read(byte[] buffer) throws IOException {
+ String pathString = new String(buffer, StandardCharsets.UTF_8);
+ return Path.of(pathString);
+ }
+
+ @Override
+ public int getTypeId() {
+ return HAZELCAST_PATH_SERIALIZER_ID;
+ }
+}
diff --git a/src/main/java/de/tum/in/www1/artemis/service/LectureImportService.java b/src/main/java/de/tum/in/www1/artemis/service/LectureImportService.java
index 0c8bc8131492..203f8ea870c9 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/LectureImportService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/LectureImportService.java
@@ -1,11 +1,13 @@
package de.tum.in.www1.artemis.service;
+import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
+
import java.io.IOException;
-import java.nio.file.Files;
+import java.net.URI;
import java.nio.file.Path;
-import java.nio.file.StandardCopyOption;
import java.util.*;
+import org.apache.commons.io.FileUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@@ -30,14 +32,14 @@ public class LectureImportService {
private final AttachmentRepository attachmentRepository;
- private final FileService fileService;
+ private final FilePathService filePathService;
public LectureImportService(LectureRepository lectureRepository, LectureUnitRepository lectureUnitRepository, AttachmentRepository attachmentRepository,
- FileService fileService) {
+ FilePathService filePathService) {
this.lectureRepository = lectureRepository;
this.lectureUnitRepository = lectureUnitRepository;
this.attachmentRepository = attachmentRepository;
- this.fileService = fileService;
+ this.filePathService = filePathService;
}
/**
@@ -151,15 +153,15 @@ private Attachment cloneAttachment(final Attachment importedAttachment) {
attachment.setVersion(importedAttachment.getVersion());
attachment.setAttachmentType(importedAttachment.getAttachmentType());
- Path oldPath = Path.of(fileService.actualPathForPublicPath(importedAttachment.getLink()));
- Path tempPath = Path.of(FilePathService.getTempFilePath(), oldPath.getFileName().toString());
+ Path oldPath = filePathService.actualPathForPublicPath(URI.create(importedAttachment.getLink()));
+ Path tempPath = FilePathService.getTempFilePath().resolve(oldPath.getFileName());
try {
log.debug("Copying attachment file from {} to {}", oldPath, tempPath);
- Files.copy(oldPath, tempPath, StandardCopyOption.REPLACE_EXISTING);
+ FileUtils.copyFile(oldPath.toFile(), tempPath.toFile(), REPLACE_EXISTING);
// File was copied to a temp directory and will be moved once we persist the attachment
- attachment.setLink(fileService.publicPathForActualPath(tempPath.toString(), null));
+ attachment.setLink(filePathService.publicPathForActualPath(tempPath, null).toString());
}
catch (IOException e) {
log.error("Error while copying file", e);
diff --git a/src/main/java/de/tum/in/www1/artemis/service/LegalDocumentService.java b/src/main/java/de/tum/in/www1/artemis/service/LegalDocumentService.java
index d3c5c857462b..22924c761d6c 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/LegalDocumentService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/LegalDocumentService.java
@@ -1,11 +1,12 @@
package de.tum.in.www1.artemis.service;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.nio.file.StandardOpenOption;
import java.util.Optional;
+import org.apache.commons.io.FileUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@@ -155,8 +156,7 @@ protected LegalDocument updateLegalDocument(LegalDocument legalDocument) {
if (!Files.exists(legalDocumentsBasePath)) {
Files.createDirectories(legalDocumentsBasePath);
}
- Files.writeString(getLegalDocumentPath(legalDocument.getLanguage(), legalDocument.getType()), legalDocument.getText(), StandardOpenOption.CREATE,
- StandardOpenOption.TRUNCATE_EXISTING);
+ FileUtils.writeStringToFile(getLegalDocumentPath(legalDocument.getLanguage(), legalDocument.getType()).toFile(), legalDocument.getText(), StandardCharsets.UTF_8);
return legalDocument;
}
catch (IOException e) {
diff --git a/src/main/java/de/tum/in/www1/artemis/service/PlantUmlService.java b/src/main/java/de/tum/in/www1/artemis/service/PlantUmlService.java
index 8006f3a40275..e1a43e3f8c39 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/PlantUmlService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/PlantUmlService.java
@@ -8,15 +8,14 @@
import java.nio.file.Paths;
import java.util.stream.Stream;
+import org.apache.commons.io.FileUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
-import net.sourceforge.plantuml.FileFormat;
-import net.sourceforge.plantuml.FileFormatOption;
-import net.sourceforge.plantuml.SourceStringReader;
+import net.sourceforge.plantuml.*;
@Service
public class PlantUmlService {
@@ -50,8 +49,7 @@ private void ensureThemes() {
log.info("Storing UML theme to temporary directory");
final var themeResource = resourceLoaderService.getResource(Path.of("puml", fileName));
try (var inputStream = themeResource.getInputStream()) {
- Files.createDirectories(PATH_TMP_THEME);
- Files.write(path, inputStream.readAllBytes());
+ FileUtils.copyToFile(inputStream, path.toFile());
log.info("UML theme stored successfully to {}", path);
}
catch (IOException e) {
diff --git a/src/main/java/de/tum/in/www1/artemis/service/QuizExerciseImportService.java b/src/main/java/de/tum/in/www1/artemis/service/QuizExerciseImportService.java
index 9d24d2383fb4..29638f7d66b5 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/QuizExerciseImportService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/QuizExerciseImportService.java
@@ -1,5 +1,7 @@
package de.tum.in.www1.artemis.service;
+import java.net.URI;
+import java.nio.file.Path;
import java.util.*;
import javax.validation.constraints.NotNull;
@@ -23,14 +25,18 @@ public class QuizExerciseImportService extends ExerciseImportService {
private final FileService fileService;
+ private final FilePathService filePathService;
+
private final ChannelService channelService;
public QuizExerciseImportService(QuizExerciseService quizExerciseService, FileService fileService, ExampleSubmissionRepository exampleSubmissionRepository,
- SubmissionRepository submissionRepository, ResultRepository resultRepository, ChannelService channelService, FeedbackService feedbackService) {
+ SubmissionRepository submissionRepository, ResultRepository resultRepository, ChannelService channelService, FeedbackService feedbackService,
+ FilePathService filePathService) {
super(exampleSubmissionRepository, submissionRepository, resultRepository, feedbackService);
this.quizExerciseService = quizExerciseService;
this.fileService = fileService;
this.channelService = channelService;
+ this.filePathService = filePathService;
}
/**
@@ -97,9 +103,15 @@ private void copyQuizQuestions(QuizExercise importedExercise, QuizExercise newEx
}
}
else if (quizQuestion instanceof DragAndDropQuestion dndQuestion) {
- // Need to copy the file and get a new path, otherwise two different questions would share the same image and would cause problems in case one was deleted
- dndQuestion
- .setBackgroundFilePath(fileService.copyExistingFileToTarget(dndQuestion.getBackgroundFilePath(), FilePathService.getDragAndDropBackgroundFilePath(), null));
+ if (dndQuestion.getBackgroundFilePath() != null) {
+ // Need to copy the file and get a new path, otherwise two different questions would share the same image and would cause problems in case one was deleted
+ Path oldPath = filePathService.actualPathForPublicPath(URI.create(dndQuestion.getBackgroundFilePath()));
+ Path newPath = fileService.copyExistingFileToTarget(oldPath, FilePathService.getDragAndDropBackgroundFilePath());
+ dndQuestion.setBackgroundFilePath(filePathService.publicPathForActualPath(newPath, null).toString());
+ }
+ else {
+ log.warn("BackgroundFilePath of DragAndDropQuestion {} is null", dndQuestion.getId());
+ }
for (DropLocation dropLocation : dndQuestion.getDropLocations()) {
dropLocation.setId(null);
@@ -110,7 +122,9 @@ else if (quizQuestion instanceof DragAndDropQuestion dndQuestion) {
dragItem.setQuestion(dndQuestion);
if (dragItem.getPictureFilePath() != null) {
// Need to copy the file and get a new path, same as above
- dragItem.setPictureFilePath(fileService.copyExistingFileToTarget(dragItem.getPictureFilePath(), FilePathService.getDragItemFilePath(), null));
+ Path oldDragItemPath = filePathService.actualPathForPublicPath(URI.create(dragItem.getPictureFilePath()));
+ Path newDragItemPath = fileService.copyExistingFileToTarget(oldDragItemPath, FilePathService.getDragItemFilePath());
+ dragItem.setPictureFilePath(filePathService.publicPathForActualPath(newDragItemPath, null).toString());
}
}
for (DragAndDropMapping dragAndDropMapping : dndQuestion.getCorrectMappings()) {
diff --git a/src/main/java/de/tum/in/www1/artemis/service/RepositoryService.java b/src/main/java/de/tum/in/www1/artemis/service/RepositoryService.java
index 324ecd8fad0d..8b294f0bdddb 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/RepositoryService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/RepositoryService.java
@@ -186,7 +186,7 @@ public Map getFilesWithInformationAboutChange(Repository reposi
public void createFile(Repository repository, String filePath, InputStream inputStream) throws IOException {
Path safePath = checkIfPathIsValidAndExistanceAndReturnSafePath(repository, filePath, false);
File file = checkIfPathAndFileAreValidAndReturnSafeFile(repository, safePath);
- Files.copy(inputStream, file.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ FileUtils.copyToFile(inputStream, file);
repository.setContent(null); // invalidate cache
inputStream.close();
}
@@ -205,7 +205,7 @@ public void createFolder(Repository repository, String folderPath, InputStream i
Files.createDirectory(repository.getLocalPath().resolve(safePath));
// We need to add an empty keep file so that the folder can be added to the git repository
File keep = new File(repository.getLocalPath().resolve(safePath).resolve(".keep"), repository);
- Files.copy(inputStream, keep.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ FileUtils.copyToFile(inputStream, keep);
repository.setContent(null); // invalidate cache
inputStream.close();
}
diff --git a/src/main/java/de/tum/in/www1/artemis/service/ResourceLoaderService.java b/src/main/java/de/tum/in/www1/artemis/service/ResourceLoaderService.java
index 42f7936fa5e9..62abfa99e07b 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/ResourceLoaderService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/ResourceLoaderService.java
@@ -2,13 +2,11 @@
import java.io.File;
import java.io.IOException;
-import java.io.InputStream;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
-import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@@ -16,6 +14,7 @@
import javax.annotation.Nonnull;
+import org.apache.commons.io.FileUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@@ -200,13 +199,10 @@ public Path getResourceFilePath(Path path) throws IOException, URISyntaxExceptio
}
else if ("jar".equals(resourceUrl.getProtocol())) {
// Resource is in a jar file.
- InputStream resourceInputStream = resource.getInputStream();
-
Path resourcePath = Files.createTempFile(UUID.randomUUID().toString(), "");
- Files.copy(resourceInputStream, resourcePath, StandardCopyOption.REPLACE_EXISTING);
- resourceInputStream.close();
- // Delete the temporary file when the JVM exits.
- resourcePath.toFile().deleteOnExit();
+ File file = resourcePath.toFile();
+ file.deleteOnExit();
+ FileUtils.copyInputStreamToFile(resource.getInputStream(), file);
return resourcePath;
}
throw new IllegalArgumentException("Unsupported protocol: " + resourceUrl.getProtocol());
diff --git a/src/main/java/de/tum/in/www1/artemis/service/SlideSplitterService.java b/src/main/java/de/tum/in/www1/artemis/service/SlideSplitterService.java
index 623f49fe8933..7c22ef6cf264 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/SlideSplitterService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/SlideSplitterService.java
@@ -4,6 +4,8 @@
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
+import java.net.URI;
+import java.nio.file.Path;
import javax.imageio.ImageIO;
@@ -33,10 +35,13 @@ public class SlideSplitterService {
private final FileService fileService;
+ private final FilePathService filePathService;
+
private final SlideRepository slideRepository;
- public SlideSplitterService(FileService fileService, SlideRepository slideRepository) {
+ public SlideSplitterService(FileService fileService, FilePathService filePathService, SlideRepository slideRepository) {
this.fileService = fileService;
+ this.filePathService = filePathService;
this.slideRepository = slideRepository;
}
@@ -47,8 +52,8 @@ public SlideSplitterService(FileService fileService, SlideRepository slideReposi
*/
@Async
public void splitAttachmentUnitIntoSingleSlides(AttachmentUnit attachmentUnit) {
- String attachmentPath = fileService.actualPathForPublicPath(attachmentUnit.getAttachment().getLink());
- File file = new File(attachmentPath);
+ Path attachmentPath = filePathService.actualPathForPublicPath(URI.create(attachmentUnit.getAttachment().getLink()));
+ File file = attachmentPath.toFile();
try (PDDocument document = Loader.loadPDF(file)) {
String pdfFilename = file.getName();
splitAttachmentUnitIntoSingleSlides(document, attachmentUnit, pdfFilename);
@@ -77,7 +82,7 @@ public void splitAttachmentUnitIntoSingleSlides(PDDocument document, AttachmentU
BufferedImage bufferedImage = pdfRenderer.renderImageWithDPI(page, 72, ImageType.RGB);
byte[] imageInByte = bufferedImageToByteArray(bufferedImage, "png");
MultipartFile slideFile = fileService.convertByteArrayToMultipart(fileNameWithOutExt + "_" + attachmentUnit.getId() + "_Slide_" + (page + 1), ".png", imageInByte);
- String filePath = fileService.handleSaveFile(slideFile, true, false);
+ String filePath = fileService.handleSaveFile(slideFile, true, false).toString();
Slide slideEntity = new Slide();
slideEntity.setSlideImagePath(filePath);
slideEntity.setSlideNumber(page + 1);
diff --git a/src/main/java/de/tum/in/www1/artemis/service/ZipFileService.java b/src/main/java/de/tum/in/www1/artemis/service/ZipFileService.java
index c686196aebdd..45cb8e38262a 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/ZipFileService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/ZipFileService.java
@@ -12,6 +12,7 @@
import javax.annotation.Nullable;
import org.apache.commons.compress.utils.FileNameUtils;
+import org.apache.commons.io.FileUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@@ -62,7 +63,7 @@ else if (Files.isReadable(path) && Files.isDirectory(path)) {
*/
public void createTemporaryZipFile(Path zipFilePath, List paths, long deleteDelayInMinutes) throws IOException {
createZipFile(zipFilePath, paths);
- fileService.scheduleForDeletion(zipFilePath, deleteDelayInMinutes);
+ fileService.schedulePathForDeletion(zipFilePath, deleteDelayInMinutes);
}
/**
@@ -119,7 +120,7 @@ private void copyToZipFile(ZipOutputStream zipOutputStream, Path path, ZipEntry
try {
if (Files.exists(path)) {
zipOutputStream.putNextEntry(zipEntry);
- Files.copy(path, zipOutputStream);
+ FileUtils.copyFile(path.toFile(), zipOutputStream);
zipOutputStream.closeEntry();
}
}
diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIContainerService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIContainerService.java
index 920471153db6..a5499626c650 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIContainerService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIContainerService.java
@@ -9,6 +9,7 @@
import java.util.concurrent.CountDownLatch;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
+import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -272,7 +273,7 @@ public Path createBuildScript(ProgrammingExercise programmingExercise, List optionalVcsUserManagementService;
+
+ private final Optional optionalCIUserManagementService;
+
public LtiService(UserCreationService userCreationService, UserRepository userRepository, ArtemisAuthenticationProvider artemisAuthenticationProvider,
- JWTCookieService jwtCookieService) {
+ JWTCookieService jwtCookieService, Optional optionalVcsUserManagementService,
+ Optional optionalCIUserManagementService) {
this.userCreationService = userCreationService;
this.userRepository = userRepository;
this.artemisAuthenticationProvider = artemisAuthenticationProvider;
this.jwtCookieService = jwtCookieService;
+ this.optionalVcsUserManagementService = optionalVcsUserManagementService;
+ this.optionalCIUserManagementService = optionalCIUserManagementService;
}
/**
@@ -112,11 +123,18 @@ private Authentication createNewUserFromLaunchRequest(String email, String usern
final User newUser;
final var groups = new HashSet();
groups.add(LTI_GROUP_NAME);
- newUser = userCreationService.createUser(username, null, groups, firstName, lastName, email, null, null, Constants.DEFAULT_LANGUAGE, true);
+
+ var password = RandomUtil.generatePassword();
+ newUser = userCreationService.createUser(username, password, groups, firstName, lastName, email, null, null, Constants.DEFAULT_LANGUAGE, true);
newUser.setActivationKey(null);
userRepository.save(newUser);
+
+ optionalVcsUserManagementService.ifPresent(vcsUserManagementService -> vcsUserManagementService.createVcsUser(newUser, password));
+ optionalCIUserManagementService.ifPresent(ciUserManagementService -> ciUserManagementService.createUser(newUser, password));
+
log.info("Created new user {}", newUser);
return newUser;
+
});
log.info("createNewUserFromLaunchRequest: {}", user);
diff --git a/src/main/java/de/tum/in/www1/artemis/service/exam/ExamUserService.java b/src/main/java/de/tum/in/www1/artemis/service/exam/ExamUserService.java
index cd2aabaaad9f..89f640e742f6 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/exam/ExamUserService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/exam/ExamUserService.java
@@ -110,7 +110,7 @@ public ExamUsersNotFoundDTO saveImages(long examId, MultipartFile file) {
ExamUser examUser = examUserOptional.get();
MultipartFile studentImageFile = fileService.convertByteArrayToMultipart(examUserWithImageDTO.studentRegistrationNumber() + "_student_image", ".png",
examUserWithImageDTO.image().imageInBytes());
- String responsePath = fileService.handleSaveFile(studentImageFile, false, false);
+ String responsePath = fileService.handleSaveFile(studentImageFile, false, false).toString();
examUser.setStudentImagePath(responsePath);
examUserRepository.save(examUser);
}
diff --git a/src/main/java/de/tum/in/www1/artemis/service/export/CourseExamExportService.java b/src/main/java/de/tum/in/www1/artemis/service/export/CourseExamExportService.java
index e2f39720073c..ddd5c15e5a5a 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/export/CourseExamExportService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/export/CourseExamExportService.java
@@ -128,7 +128,7 @@ private Optional zipExportedExercises(Path outputDir, List exportE
var exportedCourse = createCourseZipFile(courseZip, List.of(tmpDir), exportErrors);
// Delete temporary directory used for zipping
- fileService.scheduleForDirectoryDeletion(tmpDir, 1);
+ fileService.scheduleDirectoryPathForRecursiveDeletion(tmpDir, 1);
var exportState = exportErrors.isEmpty() ? CourseExamExportState.COMPLETED : CourseExamExportState.COMPLETED_WITH_WARNINGS;
notifyUserAboutExerciseExportState(notificationTopic, exportState, exportErrors);
diff --git a/src/main/java/de/tum/in/www1/artemis/service/export/DataExportCreationService.java b/src/main/java/de/tum/in/www1/artemis/service/export/DataExportCreationService.java
index ff1d639e0b3a..b059fdefba47 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/export/DataExportCreationService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/export/DataExportCreationService.java
@@ -11,6 +11,7 @@
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVPrinter;
+import org.apache.commons.io.FileUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@@ -113,7 +114,7 @@ private DataExport createDataExportWithContent(DataExport dataExport) throws IOE
private void addReadmeFile(Path workingDirectory) throws IOException, URISyntaxException {
var readmeInDataExportPath = workingDirectory.resolve("README.md");
var readmeTemplatePath = Path.of("templates", "dataexport", "README.md");
- Files.copy(resourceLoaderService.getResourceFilePath(readmeTemplatePath), readmeInDataExportPath);
+ FileUtils.copyFile(resourceLoaderService.getResourceFilePath(readmeTemplatePath).toFile(), readmeInDataExportPath.toFile());
}
/**
@@ -195,7 +196,7 @@ private Path prepareDataExport(DataExport dataExport) throws IOException {
}
dataExport = dataExportRepository.save(dataExport);
Path workingDirectory = Files.createTempDirectory(dataExportsPath, "data-export-working-dir");
- fileService.scheduleForDirectoryDeletion(workingDirectory, 30);
+ fileService.scheduleDirectoryPathForRecursiveDeletion(workingDirectory, 30);
dataExport.setDataExportState(DataExportState.IN_CREATION);
dataExportRepository.save(dataExport);
return workingDirectory;
diff --git a/src/main/java/de/tum/in/www1/artemis/service/export/DataExportExerciseCreationService.java b/src/main/java/de/tum/in/www1/artemis/service/export/DataExportExerciseCreationService.java
index ea1d17ab992c..f55ccbb21e3e 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/export/DataExportExerciseCreationService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/export/DataExportExerciseCreationService.java
@@ -5,8 +5,8 @@
import static de.tum.in.www1.artemis.service.export.DataExportUtil.retrieveCourseDirPath;
import static de.tum.in.www1.artemis.service.util.RoundingUtil.roundToNDecimalPlaces;
-import java.io.File;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
@@ -149,7 +149,7 @@ public void createProgrammingExerciseExport(ProgrammingExercise programmingExerc
// we use this directory only to clone the repository and don't do this in our current directory because the current directory is part of the final data export
// --> we can delete it after use
- var tempRepoWorkingDir = fileService.getTemporaryUniquePath(repoClonePath, 10);
+ var tempRepoWorkingDir = fileService.getTemporaryUniqueSubfolderPath(repoClonePath, 10);
programmingExerciseExportService.exportStudentRepositories(programmingExercise, listOfProgrammingExerciseParticipations, repositoryExportOptions, tempRepoWorkingDir,
exerciseDir, Collections.synchronizedList(new ArrayList<>()));
@@ -235,7 +235,7 @@ private void storeModelingSubmissionContent(ModelingSubmission modelingSubmissio
}
try (var modelAsPdf = apollonConversionService.get().convertModel(modelingSubmission.getModel())) {
- Files.write(outputDir.resolve(fileName + PDF_FILE_EXTENSION), modelAsPdf.readAllBytes());
+ FileUtils.writeByteArrayToFile(outputDir.resolve(fileName + PDF_FILE_EXTENSION).toFile(), modelAsPdf.readAllBytes());
}
catch (Exception e) {
log.warn("Failed to include the model as pdf, going to include it as plain JSON file.");
@@ -254,11 +254,11 @@ private void storeModelingSubmissionContent(ModelingSubmission modelingSubmissio
* @throws IOException if the file cannot be written
*/
private void addModelJsonWithExplanationHowToView(String model, Path outputDir, String fileName) throws IOException {
- Files.writeString(outputDir.resolve(fileName + ".json"), model);
+ FileUtils.writeStringToFile(outputDir.resolve(fileName + ".md").toFile(), model, StandardCharsets.UTF_8);
String explanation = """
You can view your model if you go to [Apollon Modeling Editor](https://www.apollon.ase.in.tum.de) and click on File --> Import and select the .json file.
""";
- Files.writeString(outputDir.resolve("view_model.md"), explanation);
+ FileUtils.writeStringToFile(outputDir.resolve("view_model.md").toFile(), explanation, StandardCharsets.UTF_8);
}
/**
@@ -271,7 +271,8 @@ private void addModelJsonWithExplanationHowToView(String model, Path outputDir,
private void storeTextSubmissionContent(TextSubmission textSubmission, Path outputDir) throws IOException {
// text can be null which leads to an exception
if (textSubmission.getText() != null) {
- Files.writeString(outputDir.resolve("text_exercise_submission_" + textSubmission.getId() + "_text.txt"), textSubmission.getText());
+ FileUtils.writeStringToFile(outputDir.resolve("text_exercise_submission_" + textSubmission.getId() + "_text.txt").toFile(), textSubmission.getText(),
+ StandardCharsets.UTF_8);
}
else {
log.warn("Cannot include text submission content in data export because content is null for submission with id: {}", textSubmission.getId());
@@ -329,7 +330,8 @@ private void createResultsAndComplaintFiles(Submission submission, Path outputDi
resultScoreAndFeedbacks.append("\n");
}
}
- Files.writeString(outputDir.resolve("submission_" + submission.getId() + "_result_" + result.getId() + TXT_FILE_EXTENSION), resultScoreAndFeedbacks);
+ FileUtils.writeStringToFile(outputDir.resolve("submission_" + submission.getId() + "_result_" + result.getId() + TXT_FILE_EXTENSION).toFile(),
+ resultScoreAndFeedbacks.toString(), StandardCharsets.UTF_8);
}
resultScoreAndFeedbacks = new StringBuilder();
}
@@ -430,9 +432,9 @@ else if (plagiarismCase.getVerdict() == PlagiarismVerdict.WARNING) {
* @param fileUploadSubmission the file upload submission for which the file should be copied
* @throws IOException if the file cannot be copied
*/
- private void copyFileUploadSubmissionFile(String submissionFilePath, Path outputDir, FileUploadSubmission fileUploadSubmission) throws IOException {
+ private void copyFileUploadSubmissionFile(Path submissionFilePath, Path outputDir, FileUploadSubmission fileUploadSubmission) throws IOException {
try {
- FileUtils.copyDirectory(new File(submissionFilePath), outputDir.toFile());
+ FileUtils.copyDirectory(submissionFilePath.toFile(), outputDir.toFile());
}
catch (IOException exception) {
log.info("Cannot include submission for file upload exercise stored at {}", submissionFilePath);
@@ -449,8 +451,8 @@ private void copyFileUploadSubmissionFile(String submissionFilePath, Path output
*/
private void addInfoThatFileForFileUploadSubmissionNoLongerExists(Path outputDir, FileUploadSubmission fileUploadSubmission) throws IOException {
var exercise = fileUploadSubmission.getParticipation().getExercise();
- Files.writeString(outputDir.resolve("submission_file_no_longer_exists.md"),
- String.format("Your submitted file for the exercise %s no longer exists on the file system.", exercise));
+ FileUtils.writeStringToFile(outputDir.resolve("submission_file_no_longer_exists.md").toFile(),
+ String.format("Your submitted file for the exercise %s no longer exists on the file system.", exercise), StandardCharsets.UTF_8);
}
/**
diff --git a/src/main/java/de/tum/in/www1/artemis/service/export/DataExportQuizExerciseCreationService.java b/src/main/java/de/tum/in/www1/artemis/service/export/DataExportQuizExerciseCreationService.java
index a89e41411f0a..dcb16d013298 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/export/DataExportQuizExerciseCreationService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/export/DataExportQuizExerciseCreationService.java
@@ -1,12 +1,13 @@
package de.tum.in.www1.artemis.service.export;
import java.io.IOException;
-import java.nio.file.Files;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import org.apache.commons.io.FileUtils;
import org.springframework.stereotype.Service;
import de.tum.in.www1.artemis.domain.participation.StudentParticipation;
@@ -73,11 +74,12 @@ else if (submittedAnswer instanceof MultipleChoiceSubmittedAnswer multipleChoice
}
}
if (!multipleChoiceQuestionsSubmissions.isEmpty()) {
- Files.write(outputDir.resolve("quiz_submission_" + submission.getId() + "_multiple_choice_questions_answers" + TXT_FILE_EXTENSION),
- multipleChoiceQuestionsSubmissions);
+ FileUtils.writeLines(outputDir.resolve("quiz_submission_" + submission.getId() + "_multiple_choice_questions_answers" + TXT_FILE_EXTENSION).toFile(),
+ StandardCharsets.UTF_8.name(), multipleChoiceQuestionsSubmissions);
}
if (!shortAnswerQuestionsSubmissions.isEmpty()) {
- Files.write(outputDir.resolve("quiz_submission_" + submission.getId() + "_short_answer_questions_answers" + TXT_FILE_EXTENSION), shortAnswerQuestionsSubmissions);
+ FileUtils.writeLines(outputDir.resolve("quiz_submission_" + submission.getId() + "_short_answer_questions_answers" + TXT_FILE_EXTENSION).toFile(),
+ StandardCharsets.UTF_8.name(), shortAnswerQuestionsSubmissions);
}
}
diff --git a/src/main/java/de/tum/in/www1/artemis/service/export/DataExportService.java b/src/main/java/de/tum/in/www1/artemis/service/export/DataExportService.java
index 6972474acadd..19890057c762 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/export/DataExportService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/export/DataExportService.java
@@ -150,7 +150,7 @@ public void deleteDataExportAndSetDataExportState(DataExport dataExport) {
if (dataExport.getFilePath() == null) {
return;
}
- fileService.scheduleForDeletion(Path.of(dataExport.getFilePath()), 2);
+ fileService.schedulePathForDeletion(Path.of(dataExport.getFilePath()), 2);
if (dataExport.getDataExportState().hasBeenDownloaded()) {
dataExport.setDataExportState(DataExportState.DOWNLOADED_DELETED);
}
diff --git a/src/main/java/de/tum/in/www1/artemis/service/export/ExerciseWithSubmissionsExportService.java b/src/main/java/de/tum/in/www1/artemis/service/export/ExerciseWithSubmissionsExportService.java
index 691c4c875843..b57dcda0b5b9 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/export/ExerciseWithSubmissionsExportService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/export/ExerciseWithSubmissionsExportService.java
@@ -1,12 +1,14 @@
package de.tum.in.www1.artemis.service.export;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import org.apache.commons.io.FileUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
@@ -71,7 +73,7 @@ private void exportProblemStatementWithEmbeddedFiles(Exercise exercise, List exp
*/
private void constructFilenameAndCopyFile(Exercise exercise, List exportErrors, Path embeddedFilesDir, String filePath) {
String fileName = filePath.replace(API_MARKDOWN_FILE_PATH, "");
- Path imageFilePath = Path.of(FilePathService.getMarkdownFilePath(), fileName);
+ Path imageFilePath = FilePathService.getMarkdownFilePath().resolve(fileName);
Path imageExportPath = embeddedFilesDir.resolve(fileName);
// we need this check as it might be that the matched string is different and not filtered out above but the file is already copied
if (!Files.exists(imageExportPath)) {
try {
- Files.copy(imageFilePath, imageExportPath);
+ FileUtils.copyFile(imageFilePath.toFile(), imageExportPath.toFile());
}
catch (IOException e) {
exportErrors.add("Failed to copy embedded files: " + e.getMessage());
diff --git a/src/main/java/de/tum/in/www1/artemis/service/export/FileUploadSubmissionExportService.java b/src/main/java/de/tum/in/www1/artemis/service/export/FileUploadSubmissionExportService.java
index a8302aff65c5..928c98d4b9c5 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/export/FileUploadSubmissionExportService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/export/FileUploadSubmissionExportService.java
@@ -6,6 +6,7 @@
import java.nio.file.Path;
import java.util.regex.Pattern;
+import org.apache.commons.io.FileUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@@ -34,17 +35,16 @@ protected void saveSubmissionToFile(Exercise exercise, Submission submission, Fi
}
// we need to get the 'real' file path here, the submission only has the api url path
- String filePath = FileUploadSubmission.buildFilePath(exercise.getId(), submission.getId());
- Path filePathPath = Path.of(filePath);
+ Path filePath = FileUploadSubmission.buildFilePath(exercise.getId(), submission.getId());
- if (!Files.exists(filePathPath)) { // throw if submission file does not exist
- throw new IOException("Cannot export submission " + submission.getId() + " because the uploaded file " + filePathPath + " doesn't exist.");
+ if (!Files.exists(filePath)) { // throw if submission file does not exist
+ throw new IOException("Cannot export submission " + submission.getId() + " because the uploaded file " + filePath + " doesn't exist.");
}
- try (var files = Files.list(filePathPath)) {
+ try (var files = Files.list(filePath)) {
files.forEach(content -> {
try {
- Files.copy(content, file.toPath());
+ FileUtils.copyFile(content.toFile(), file);
}
catch (IOException e) {
log.error("Failed to copy file {} to zip file", content, e);
diff --git a/src/main/java/de/tum/in/www1/artemis/service/export/ProgrammingExerciseExportService.java b/src/main/java/de/tum/in/www1/artemis/service/export/ProgrammingExerciseExportService.java
index 29bafc2e0296..400e771aea10 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/export/ProgrammingExerciseExportService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/export/ProgrammingExerciseExportService.java
@@ -675,7 +675,7 @@ private Path createZipForRepositoryWithParticipation(final ProgrammingExercise p
try {
log.debug("Normalizing code style for participation {}", participation);
fileService.normalizeLineEndingsDirectory(repository.getLocalPath());
- fileService.convertToUTF8Directory(repository.getLocalPath());
+ fileService.convertFilesInDirectoryToUtf8(repository.getLocalPath());
}
catch (IOException ex) {
log.warn("Cannot normalize code style in the repository {} due to the following exception: {}", repository.getLocalPath(), ex.getMessage());
diff --git a/src/main/java/de/tum/in/www1/artemis/service/export/SubmissionExportService.java b/src/main/java/de/tum/in/www1/artemis/service/export/SubmissionExportService.java
index 9bf450a76ccf..545971416039 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/export/SubmissionExportService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/export/SubmissionExportService.java
@@ -82,7 +82,7 @@ public File exportStudentSubmissionsElseThrow(Long exerciseId, SubmissionExportO
* @return the zipped file with the exported submissions
*/
public List exportStudentSubmissions(Long exerciseId, SubmissionExportOptionsDTO submissionExportOptions) {
- Path outputDir = fileService.getTemporaryUniquePath(submissionExportPath, EXPORTED_SUBMISSIONS_DELETION_DELAY_IN_MINUTES);
+ Path outputDir = fileService.getTemporaryUniqueSubfolderPath(submissionExportPath, EXPORTED_SUBMISSIONS_DELETION_DELAY_IN_MINUTES);
return exportStudentSubmissions(exerciseId, submissionExportOptions, true, outputDir, new ArrayList<>(), new ArrayList<>());
}
diff --git a/src/main/java/de/tum/in/www1/artemis/service/plagiarism/ProgrammingPlagiarismDetectionService.java b/src/main/java/de/tum/in/www1/artemis/service/plagiarism/ProgrammingPlagiarismDetectionService.java
index ca47d0ef6f4e..b135284995e9 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/plagiarism/ProgrammingPlagiarismDetectionService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/plagiarism/ProgrammingPlagiarismDetectionService.java
@@ -169,7 +169,7 @@ public File checkPlagiarismWithJPlagReport(long programmingExerciseId, float sim
@NotNull
private JPlagResult computeJPlagResult(ProgrammingExercise programmingExercise, float similarityThreshold, int minimumScore) {
long programmingExerciseId = programmingExercise.getId();
- final var targetPath = fileService.getTemporaryUniquePath(repoDownloadClonePath, 60);
+ final var targetPath = fileService.getTemporaryUniqueSubfolderPath(repoDownloadClonePath, 60);
List participations = filterStudentParticipationsForComparison(programmingExercise, minimumScore);
log.info("Download repositories for JPlag for programming exercise {} to compare {} participations", programmingExerciseId, participations.size());
@@ -245,9 +245,9 @@ private void limitAndSavePlagiarismResult(TextPlagiarismResult textPlagiarismRes
* @return the zip file
*/
public File generateJPlagReportZip(JPlagResult jPlagResult, ProgrammingExercise programmingExercise) {
- final var targetPath = fileService.getTemporaryUniquePath(repoDownloadClonePath, 5);
- final var reportFolder = targetPath.resolve(programmingExercise.getProjectKey() + " JPlag Report").toString();
- final var reportFolderFile = new File(reportFolder);
+ final var targetPath = fileService.getTemporaryUniqueSubfolderPath(repoDownloadClonePath, 5);
+ final var reportFolder = targetPath.resolve(programmingExercise.getProjectKey() + " JPlag Report");
+ final var reportFolderFile = reportFolder.toFile();
// Create directories.
if (!reportFolderFile.mkdirs()) {
@@ -259,11 +259,11 @@ public File generateJPlagReportZip(JPlagResult jPlagResult, ProgrammingExercise
// Write JPlag report result to the file.
log.info("Write JPlag report to file system and zip it");
ReportObjectFactory reportObjectFactory = new ReportObjectFactory();
- reportObjectFactory.createAndSaveReport(jPlagResult, reportFolder);
+ reportObjectFactory.createAndSaveReport(jPlagResult, reportFolder.toString());
// JPlag automatically zips the report
var zipFile = new File(reportFolder + ".zip");
- fileService.scheduleForDeletion(zipFile.getAbsoluteFile().toPath(), 1);
+ fileService.schedulePathForDeletion(zipFile.getAbsoluteFile().toPath(), 1);
return zipFile;
}
diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/JavaTemplateUpgradeService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/JavaTemplateUpgradeService.java
index 692b99aeb043..e0cc65715604 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/programming/JavaTemplateUpgradeService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/programming/JavaTemplateUpgradeService.java
@@ -1,12 +1,11 @@
package de.tum.in.www1.artemis.service.programming;
import java.io.*;
-import java.nio.file.Files;
import java.nio.file.Path;
-import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.function.Predicate;
+import org.apache.commons.io.FileUtils;
import org.apache.maven.model.Dependency;
import org.apache.maven.model.Model;
import org.apache.maven.model.Plugin;
@@ -290,7 +289,7 @@ private void overwriteFilesIfPresent(Repository repository, Resource[] templateR
Optional templateResource = getFileByName(templateResources, filename);
if (repoFile.isPresent() && templateResource.isPresent()) {
try (InputStream inputStream = templateResource.get().getInputStream()) {
- Files.copy(inputStream, repoFile.get().toPath(), StandardCopyOption.REPLACE_EXISTING);
+ FileUtils.copyToFile(inputStream, repoFile.get());
}
}
}
diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseImportFromFileService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseImportFromFileService.java
index fcd9fe6c3999..2d085d54a80d 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseImportFromFileService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseImportFromFileService.java
@@ -89,7 +89,7 @@ public ProgrammingExercise importProgrammingExerciseFromFile(ProgrammingExercise
}
finally {
// want to make sure the directories are deleted, even if an exception is thrown
- fileService.scheduleForDirectoryDeletion(importExerciseDir, 5);
+ fileService.scheduleDirectoryPathForRecursiveDeletion(importExerciseDir, 5);
}
return importedProgrammingExercise;
}
@@ -106,11 +106,10 @@ private void copyEmbeddedFiles(Path importExerciseDir) throws IOException {
return;
}
try (var embeddedFiles = Files.list(embeddedFilesDir)) {
- for (var file : embeddedFiles.toList()) {
- var targetPath = Path.of(FilePathService.getMarkdownFilePath(), file.getFileName().toString());
- // we need this check because the detection if a file exists of Files.copy seems not to work properly
+ for (Path file : embeddedFiles.toList()) {
+ Path targetPath = FilePathService.getMarkdownFilePath().resolve(file.getFileName());
if (!Files.exists(targetPath)) {
- Files.copy(file, targetPath);
+ FileUtils.copyFile(file.toFile(), targetPath.toFile());
}
}
}
diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseRepositoryService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseRepositoryService.java
index b43038b0a0b6..65f093083484 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseRepositoryService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseRepositoryService.java
@@ -10,6 +10,7 @@
import java.time.ZonedDateTime;
import java.util.*;
+import org.apache.commons.io.FileUtils;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -310,7 +311,7 @@ private void setupJVMTestTemplateAndPush(final RepositoryResources resources, fi
if (ProjectType.MAVEN_BLACKBOX.equals(projectType)) {
Path dejagnuLibFolderPath = repoLocalPath.resolve("testsuite").resolve("lib");
- fileService.replaceVariablesInFileName(dejagnuLibFolderPath.toString(), PACKAGE_NAME_FILE_PLACEHOLDER, programmingExercise.getPackageName());
+ fileService.replaceVariablesInFilename(dejagnuLibFolderPath, PACKAGE_NAME_FILE_PLACEHOLDER, programmingExercise.getPackageName());
}
final Map sectionsMap = new HashMap<>();
@@ -422,7 +423,7 @@ private void setupBuildToolProjectFile(final Path repoLocalPath, final ProjectTy
projectFileFileName = POM_XML;
}
- fileService.replacePlaceholderSections(repoLocalPath.resolve(projectFileFileName).toAbsolutePath().toString(), activeFeatures);
+ fileService.replacePlaceholderSections(repoLocalPath.resolve(projectFileFileName).toAbsolutePath(), activeFeatures);
}
private void setupStaticCodeAnalysisConfigFiles(final RepositoryResources resources, final Path templatePath, final Path repoLocalPath) throws IOException {
@@ -483,7 +484,7 @@ private void setupTestTemplateSequentialTestRuns(final RepositoryResources resou
final Path repoLocalPath = getRepoAbsoluteLocalPath(resources.repository);
- fileService.replacePlaceholderSections(repoLocalPath.resolve(projectFileName).toAbsolutePath().toString(), sectionsMap);
+ fileService.replacePlaceholderSections(repoLocalPath.resolve(projectFileName).toAbsolutePath(), sectionsMap);
final Optional stagePomXml = getStagePomXml(templatePath, projectTemplatePath, isMaven);
@@ -545,7 +546,7 @@ private void setupBuildStage(final Path resourcePrefix, final Path templatePath,
// staging project files are only required for maven
final boolean isMaven = isMavenProject(projectType);
if (isMaven && stagePomXml.isPresent()) {
- Files.copy(stagePomXml.get().getInputStream(), buildStagePath.resolve(POM_XML));
+ FileUtils.copyFile(stagePomXml.get().getFile(), buildStagePath.resolve(POM_XML).toFile());
}
final Path buildStageResourcesPath = templatePath.resolve(TEST_FILES_PATH).resolve(buildStageTemplateSubDirectory);
@@ -582,8 +583,7 @@ void replacePlaceholders(final ProgrammingExercise programmingExercise, final Re
switch (programmingLanguage) {
case JAVA, KOTLIN -> {
- fileService.replaceVariablesInDirectoryName(getRepoAbsoluteLocalPath(repository).toString(), PACKAGE_NAME_FOLDER_PLACEHOLDER,
- programmingExercise.getPackageFolderName());
+ fileService.replaceVariablesInDirectoryName(getRepoAbsoluteLocalPath(repository), PACKAGE_NAME_FOLDER_PLACEHOLDER, programmingExercise.getPackageFolderName());
replacements.put(PACKAGE_NAME_PLACEHOLDER, programmingExercise.getPackageName());
}
case SWIFT -> replaceSwiftPlaceholders(replacements, programmingExercise, repository);
@@ -608,20 +608,23 @@ void replacePlaceholders(final ProgrammingExercise programmingExercise, final Re
* @throws IOException Thrown if accessing repository files fails.
*/
private void replaceSwiftPlaceholders(final Map replacements, final ProgrammingExercise programmingExercise, final Repository repository) throws IOException {
- final String repositoryLocalPath = getRepoAbsoluteLocalPath(repository).toString();
+ final Path repositoryLocalPath = getRepoAbsoluteLocalPath(repository);
final String packageName = programmingExercise.getPackageName();
+ // The client already provides a clean package name, but we have to make sure that no one abuses the API for injection.
+ // So usually, the name should not change.
+ final String cleanPackageName = packageName.replaceAll("[^a-zA-Z\\d]", "");
if (ProjectType.PLAIN.equals(programmingExercise.getProjectType())) {
- fileService.replaceVariablesInDirectoryName(repositoryLocalPath, PACKAGE_NAME_FOLDER_PLACEHOLDER, packageName);
- fileService.replaceVariablesInFileName(repositoryLocalPath, PACKAGE_NAME_FILE_PLACEHOLDER, packageName);
+ fileService.replaceVariablesInDirectoryName(repositoryLocalPath, PACKAGE_NAME_FOLDER_PLACEHOLDER, cleanPackageName);
+ fileService.replaceVariablesInFilename(repositoryLocalPath, PACKAGE_NAME_FILE_PLACEHOLDER, cleanPackageName);
- replacements.put(PACKAGE_NAME_PLACEHOLDER, packageName);
+ replacements.put(PACKAGE_NAME_PLACEHOLDER, cleanPackageName);
}
else if (ProjectType.XCODE.equals(programmingExercise.getProjectType())) {
- fileService.replaceVariablesInDirectoryName(repositoryLocalPath, APP_NAME_PLACEHOLDER, packageName);
- fileService.replaceVariablesInFileName(repositoryLocalPath, APP_NAME_PLACEHOLDER, packageName);
+ fileService.replaceVariablesInDirectoryName(repositoryLocalPath, APP_NAME_PLACEHOLDER, cleanPackageName);
+ fileService.replaceVariablesInFilename(repositoryLocalPath, APP_NAME_PLACEHOLDER, cleanPackageName);
- replacements.put(APP_NAME_PLACEHOLDER, packageName);
+ replacements.put(APP_NAME_PLACEHOLDER, cleanPackageName);
}
}
diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseService.java
index 459b46953ca8..536f716a23a8 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseService.java
@@ -4,6 +4,7 @@
import static de.tum.in.www1.artemis.domain.enumeration.BuildPlanType.TEMPLATE;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.ZonedDateTime;
@@ -14,6 +15,7 @@
import javax.annotation.Nullable;
+import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.jgit.api.errors.GitAPIException;
@@ -566,7 +568,7 @@ private boolean saveAndPushStructuralOracle(User user, Repository testRepository
// If not, then update the oracle file by rewriting it and push the changes.
if (!Files.exists(structureOraclePath)) {
try {
- Files.write(structureOraclePath, structureOracleJSON.getBytes());
+ FileUtils.writeStringToFile(structureOraclePath.toFile(), structureOracleJSON, StandardCharsets.UTF_8);
gitService.stageAllChanges(testRepository);
gitService.commitAndPush(testRepository, "Generate the structure oracle file.", true, user);
return true;
@@ -586,7 +588,7 @@ private boolean saveAndPushStructuralOracle(User user, Repository testRepository
}
else {
try {
- Files.write(structureOraclePath, structureOracleJSON.getBytes());
+ FileUtils.writeStringToFile(structureOraclePath.toFile(), structureOracleJSON, StandardCharsets.UTF_8);
gitService.stageAllChanges(testRepository);
gitService.commitAndPush(testRepository, "Update the structure oracle file.", true, user);
return true;
diff --git a/src/main/java/de/tum/in/www1/artemis/service/scheduled/NotificationScheduleService.java b/src/main/java/de/tum/in/www1/artemis/service/scheduled/NotificationScheduleService.java
index d9bb867f3bc4..d1f49b6e1997 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/scheduled/NotificationScheduleService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/scheduled/NotificationScheduleService.java
@@ -164,8 +164,9 @@ private void scheduleNotificationForAssessedExercisesSubmissions(Exercise exerci
* @return true if the time is valid else false
*/
private boolean checkIfTimeIsCorrectForScheduledTask(ZonedDateTime relevantTime) {
- // only send a notification if relevantTime is defined and not in the future (i.e. in the range [now-2 minutes, now]) (due to possible delays in scheduling)
- return relevantTime != null && !relevantTime.isBefore(ZonedDateTime.now().minusMinutes(2)) && !relevantTime.isAfter(ZonedDateTime.now());
+ // Only send a notification if relevantTime is defined and close to the current time (i.e. in the range [now-2 minutes, now+2 minutes]) (due to possible delays in
+ // scheduling)
+ return relevantTime != null && !relevantTime.isBefore(ZonedDateTime.now().minusMinutes(2)) && !relevantTime.isAfter(ZonedDateTime.now().plusMinutes(2));
}
/**
diff --git a/src/main/java/de/tum/in/www1/artemis/service/user/UserCreationService.java b/src/main/java/de/tum/in/www1/artemis/service/user/UserCreationService.java
index 0f1ff37b08e5..c1f4b98fafb6 100644
--- a/src/main/java/de/tum/in/www1/artemis/service/user/UserCreationService.java
+++ b/src/main/java/de/tum/in/www1/artemis/service/user/UserCreationService.java
@@ -7,7 +7,6 @@
import java.util.Optional;
import java.util.Set;
import java.util.regex.PatternSyntaxException;
-import java.util.stream.Collectors;
import javax.annotation.Nullable;
import javax.validation.constraints.NotNull;
@@ -162,11 +161,9 @@ public User createUser(ManagedUserVM userDTO) {
else {
user.setLangKey(userDTO.getLangKey());
}
- if (userDTO.getAuthorities() != null) {
- Set authorities = userDTO.getAuthorities().stream().map(authorityRepository::findById).filter(Optional::isPresent).map(Optional::get)
- .collect(Collectors.toSet());
- user.setAuthorities(authorities);
- }
+
+ setUserAuthorities(userDTO, user);
+
String password = userDTO.getPassword() == null ? RandomUtil.generatePassword() : userDTO.getPassword();
String passwordHash = passwordService.hashPassword(password);
user.setPassword(passwordHash);
@@ -197,6 +194,24 @@ public User createUser(ManagedUserVM userDTO) {
return user;
}
+ /**
+ * Updates the authorities for the user according to the ones set in the DTO.
+ *
+ * @param userDTO The source for the authorities that should be set.
+ * @param user The target user where the authorities are set.
+ */
+ private void setUserAuthorities(final ManagedUserVM userDTO, final User user) {
+ // A user needs to have at least some role, otherwise an authentication token can never be constructed
+ if (userDTO.getAuthorities() == null || userDTO.getAuthorities().isEmpty()) {
+ userDTO.setAuthorities(Set.of(STUDENT.getAuthority()));
+ }
+
+ // clear and add instead of new Set for Hibernate change tracking
+ final Set authorities = user.getAuthorities();
+ authorities.clear();
+ userDTO.getAuthorities().stream().map(authorityRepository::findById).flatMap(Optional::stream).forEach(authorities::add);
+ }
+
/**
* Update basic information (first name, last name, email, language) for the current user.
* This method is typically invoked by the user
@@ -244,9 +259,8 @@ public User updateUser(@NotNull User user, ManagedUserVM updatedUserDTO) {
user.setPassword(passwordService.hashPassword(updatedUserDTO.getPassword()));
}
user.setOrganizations(updatedUserDTO.getOrganizations());
- Set managedAuthorities = user.getAuthorities();
- managedAuthorities.clear();
- updatedUserDTO.getAuthorities().stream().map(authorityRepository::findById).filter(Optional::isPresent).map(Optional::get).forEach(managedAuthorities::add);
+ setUserAuthorities(updatedUserDTO, user);
+
log.debug("Changed Information for User: {}", user);
return saveUser(user);
diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/AttachmentResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/AttachmentResource.java
index 961db819c051..47e27d39c576 100644
--- a/src/main/java/de/tum/in/www1/artemis/web/rest/AttachmentResource.java
+++ b/src/main/java/de/tum/in/www1/artemis/web/rest/AttachmentResource.java
@@ -24,6 +24,7 @@
import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastInstructor;
import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastTutor;
import de.tum.in.www1.artemis.service.AuthorizationCheckService;
+import de.tum.in.www1.artemis.service.FilePathService;
import de.tum.in.www1.artemis.service.FileService;
import de.tum.in.www1.artemis.service.notifications.GroupNotificationService;
import de.tum.in.www1.artemis.web.rest.util.HeaderUtil;
@@ -53,15 +54,18 @@ public class AttachmentResource {
private final FileService fileService;
+ private final FilePathService filePathService;
+
private final CacheManager cacheManager;
public AttachmentResource(AttachmentRepository attachmentRepository, GroupNotificationService groupNotificationService, AuthorizationCheckService authorizationCheckService,
- UserRepository userRepository, FileService fileService, CacheManager cacheManager) {
+ UserRepository userRepository, FileService fileService, FilePathService filePathService, CacheManager cacheManager) {
this.attachmentRepository = attachmentRepository;
this.groupNotificationService = groupNotificationService;
this.authorizationCheckService = authorizationCheckService;
this.userRepository = userRepository;
this.fileService = fileService;
+ this.filePathService = filePathService;
this.cacheManager = cacheManager;
}
@@ -79,11 +83,11 @@ public ResponseEntity createAttachment(@RequestPart Attachment attac
log.debug("REST request to save Attachment : {}", attachment);
attachment.setId(null);
- String pathString = fileService.handleSaveFile(file, false, false);
+ String pathString = fileService.handleSaveFile(file, false, false).toString();
attachment.setLink(pathString);
Attachment result = attachmentRepository.save(attachment);
- this.cacheManager.getCache("files").evict(fileService.actualPathForPublicPath(result.getLink()));
+ this.cacheManager.getCache("files").evict(filePathService.actualPathForPublicPath(URI.create(result.getLink())).toString());
return ResponseEntity.created(new URI("/api/attachments/" + result.getId())).body(result);
}
@@ -109,12 +113,12 @@ public ResponseEntity updateAttachment(@PathVariable Long attachment
attachment.setAttachmentUnit(originalAttachment.getAttachmentUnit());
if (file != null) {
- String pathString = fileService.handleSaveFile(file, false, false);
+ String pathString = fileService.handleSaveFile(file, false, false).toString();
attachment.setLink(pathString);
}
Attachment result = attachmentRepository.save(attachment);
- this.cacheManager.getCache("files").evict(fileService.actualPathForPublicPath(result.getLink()));
+ this.cacheManager.getCache("files").evict(filePathService.actualPathForPublicPath(URI.create(result.getLink())).toString());
if (notificationText != null) {
groupNotificationService.notifyStudentGroupAboutAttachmentChange(result, notificationText);
}
@@ -169,7 +173,7 @@ public ResponseEntity deleteAttachment(@PathVariable Long attachmentId) {
course = attachment.getLecture().getCourse();
relatedEntity = "lecture " + attachment.getLecture().getTitle();
try {
- this.cacheManager.getCache("files").evict(fileService.actualPathForPublicPath(attachment.getLink()));
+ this.cacheManager.getCache("files").evict(filePathService.actualPathForPublicPath(URI.create(attachment.getLink())).toString());
}
catch (RuntimeException exception) {
// this catch is required for deleting wrongly formatted attachment database entries
diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/CourseResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/CourseResource.java
index a5a26daf1153..dd503338932c 100644
--- a/src/main/java/de/tum/in/www1/artemis/web/rest/CourseResource.java
+++ b/src/main/java/de/tum/in/www1/artemis/web/rest/CourseResource.java
@@ -219,7 +219,7 @@ public ResponseEntity updateCourse(@PathVariable Long courseId, @Request
courseUpdate.validateUnenrollmentEndDate();
if (file != null) {
- String pathString = fileService.handleSaveFile(file, false, false);
+ String pathString = fileService.handleSaveFile(file, false, false).toString();
courseUpdate.setCourseIcon(pathString);
}
diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ExamUserResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ExamUserResource.java
index f4651b7b789f..e4006712c5b1 100644
--- a/src/main/java/de/tum/in/www1/artemis/web/rest/ExamUserResource.java
+++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ExamUserResource.java
@@ -71,7 +71,7 @@ public ResponseEntity updateExamUser(@RequestPart ExamUserDTO examUser
.orElseThrow(() -> new EntityNotFoundException("Exam user with login: \"" + examUserDTO.login() + "\" does not exist"));
if (signatureFile != null) {
- String responsePath = fileService.handleSaveFile(signatureFile, true, false);
+ String responsePath = fileService.handleSaveFile(signatureFile, true, false).toString();
examUser.setSigningImagePath(responsePath);
}
examUser.setDidCheckImage(examUserDTO.didCheckImage());
diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/FileResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/FileResource.java
index 7cd98dca5191..2464683b3a36 100644
--- a/src/main/java/de/tum/in/www1/artemis/web/rest/FileResource.java
+++ b/src/main/java/de/tum/in/www1/artemis/web/rest/FileResource.java
@@ -58,6 +58,8 @@ public class FileResource {
private final FileService fileService;
+ private final FilePathService filePathService;
+
private final ResourceLoaderService resourceLoaderService;
private final LectureRepository lectureRepository;
@@ -86,10 +88,12 @@ public class FileResource {
private final CourseRepository courseRepository;
- public FileResource(SlideRepository slideRepository, AuthorizationCheckService authorizationCheckService, FileService fileService, ResourceLoaderService resourceLoaderService,
- LectureRepository lectureRepository, FileUploadSubmissionRepository fileUploadSubmissionRepository, FileUploadExerciseRepository fileUploadExerciseRepository,
- AttachmentRepository attachmentRepository, AttachmentUnitRepository attachmentUnitRepository, AuthorizationCheckService authCheckService, UserRepository userRepository,
- ExamUserRepository examUserRepository, QuizQuestionRepository quizQuestionRepository, DragItemRepository dragItemRepository, CourseRepository courseRepository) {
+ public FileResource(FilePathService filePathService, SlideRepository slideRepository, AuthorizationCheckService authorizationCheckService, FileService fileService,
+ ResourceLoaderService resourceLoaderService, LectureRepository lectureRepository, FileUploadSubmissionRepository fileUploadSubmissionRepository,
+ FileUploadExerciseRepository fileUploadExerciseRepository, AttachmentRepository attachmentRepository, AttachmentUnitRepository attachmentUnitRepository,
+ AuthorizationCheckService authCheckService, UserRepository userRepository, ExamUserRepository examUserRepository, QuizQuestionRepository quizQuestionRepository,
+ DragItemRepository dragItemRepository, CourseRepository courseRepository) {
+ this.filePathService = filePathService;
this.fileService = fileService;
this.resourceLoaderService = resourceLoaderService;
this.lectureRepository = lectureRepository;
@@ -122,7 +126,7 @@ public FileResource(SlideRepository slideRepository, AuthorizationCheckService a
@EnforceAtLeastTutor
public ResponseEntity saveFile(@RequestParam(value = "file") MultipartFile file, @RequestParam(defaultValue = "false") boolean keepFileName) throws URISyntaxException {
log.debug("REST request to upload file : {}", file.getOriginalFilename());
- String responsePath = fileService.handleSaveFile(file, keepFileName, false);
+ String responsePath = fileService.handleSaveFile(file, keepFileName, false).toString();
// return path for getting the file
String responseBody = "{\"path\":\"" + responsePath + "\"}";
@@ -142,7 +146,7 @@ public ResponseEntity saveFile(@RequestParam(value = "file") MultipartFi
public ResponseEntity getTempFile(@PathVariable String filename) {
log.debug("REST request to get file : {}", filename);
sanitizeFilenameElseThrow(filename);
- return responseEntityForFilePath(Path.of(FilePathService.getTempFilePath(), filename).toString());
+ return responseEntityForFilePath(FilePathService.getTempFilePath().resolve(filename));
}
/**
@@ -158,7 +162,7 @@ public ResponseEntity getTempFile(@PathVariable String filename) {
public ResponseEntity saveMarkdownFile(@RequestParam(value = "file") MultipartFile file, @RequestParam(defaultValue = "false") boolean keepFileName)
throws URISyntaxException {
log.debug("REST request to upload file for markdown: {}", file.getOriginalFilename());
- String responsePath = fileService.handleSaveFile(file, keepFileName, true);
+ String responsePath = fileService.handleSaveFile(file, keepFileName, true).toString();
// return path for getting the file
String responseBody = "{\"path\":\"" + responsePath + "\"}";
@@ -233,7 +237,7 @@ public ResponseEntity getDragAndDropBackgroundFile(@PathVariable Long qu
DragAndDropQuestion question = quizQuestionRepository.findDnDQuestionByIdOrElseThrow(questionId);
Course course = question.getExercise().getCourseViaExerciseGroupOrCourseMember();
authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, null);
- return responseEntityForFilePath(fileService.actualPathForPublicPath(question.getBackgroundFilePath()));
+ return responseEntityForFilePath(filePathService.actualPathForPublicPath(URI.create(question.getBackgroundFilePath())));
}
/**
@@ -252,7 +256,7 @@ public ResponseEntity getDragItemFile(@PathVariable Long dragItemId) {
if (dragItem.getPictureFilePath() == null) {
throw new EntityNotFoundException("Drag item " + dragItemId + " has no picture file");
}
- return responseEntityForFilePath(fileService.actualPathForPublicPath(dragItem.getPictureFilePath()));
+ return responseEntityForFilePath(filePathService.actualPathForPublicPath(URI.create(dragItem.getPictureFilePath())));
}
/**
@@ -286,7 +290,7 @@ public ResponseEntity getFileUploadSubmission(@PathVariable Long exercis
throw new AccessForbiddenException();
}
- return buildFileResponse(fileService.actualPathForPublicPath(submission.getFilePath()), false);
+ return buildFileResponse(filePathService.actualPathForPublicPath(URI.create(submission.getFilePath())), false);
}
/**
@@ -301,7 +305,7 @@ public ResponseEntity getCourseIcon(@PathVariable Long courseId) {
log.debug("REST request to get icon for course : {}", courseId);
Course course = courseRepository.findByIdElseThrow(courseId);
authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, null);
- return responseEntityForFilePath(fileService.actualPathForPublicPath(course.getCourseIcon()));
+ return responseEntityForFilePath(filePathService.actualPathForPublicPath(URI.create(course.getCourseIcon())));
}
/**
@@ -317,7 +321,7 @@ public ResponseEntity getUserSignature(@PathVariable Long examUserId) {
ExamUser examUser = examUserRepository.findWithExamById(examUserId).orElseThrow();
authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, examUser.getExam().getCourse(), null);
- return buildFileResponse(fileService.actualPathForPublicPath(examUser.getSigningImagePath()), false);
+ return buildFileResponse(filePathService.actualPathForPublicPath(URI.create(examUser.getSigningImagePath())), false);
}
/**
@@ -333,7 +337,7 @@ public ResponseEntity getExamUserImage(@PathVariable Long examUserId) {
ExamUser examUser = examUserRepository.findWithExamById(examUserId).orElseThrow();
authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, examUser.getExam().getCourse(), null);
- return buildFileResponse(fileService.actualPathForPublicPath(examUser.getStudentImagePath()), true);
+ return buildFileResponse(filePathService.actualPathForPublicPath(URI.create(examUser.getStudentImagePath())), true);
}
/**
@@ -360,7 +364,7 @@ public ResponseEntity getLectureAttachment(@PathVariable Long lectureId,
// check if the user is authorized to access the requested attachment unit
checkAttachmentAuthorizationOrThrow(course, attachment);
- return buildFileResponse(fileService.actualPathForPublicPath(attachment.getLink()), false);
+ return buildFileResponse(filePathService.actualPathForPublicPath(URI.create(attachment.getLink())), false);
}
/**
@@ -385,8 +389,8 @@ public ResponseEntity getLecturePdfAttachmentsMerged(@PathVariable Long
List attachmentLinks = lectureAttachments.stream()
.filter(unit -> authCheckService.isAllowedToSeeLectureUnit(unit, user) && "pdf".equals(StringUtils.substringAfterLast(unit.getAttachment().getLink(), ".")))
- .map(unit -> Path.of(FilePathService.getAttachmentUnitFilePath(), String.valueOf(unit.getId()), StringUtils.substringAfterLast(unit.getAttachment().getLink(), "/"))
- .toString())
+ .map(unit -> FilePathService.getAttachmentUnitFilePath()
+ .resolve(Path.of(String.valueOf(unit.getId()), StringUtils.substringAfterLast(unit.getAttachment().getLink(), "/"))).toString())
.toList();
Optional file = fileService.mergePdfFiles(attachmentLinks, lectureRepository.getLectureTitle(lectureId));
@@ -417,7 +421,7 @@ public ResponseEntity getAttachmentUnitAttachment(@PathVariable Long att
// check if the user is authorized to access the requested attachment unit
checkAttachmentAuthorizationOrThrow(course, attachment);
- return buildFileResponse(fileService.actualPathForPublicPath(attachment.getLink()), false);
+ return buildFileResponse(filePathService.actualPathForPublicPath(URI.create(attachment.getLink())), false);
}
/**
@@ -448,8 +452,8 @@ public ResponseEntity getAttachmentUnitAttachmentSlide(@PathVariable Lon
if (matcher.matches()) {
String fileName = matcher.group(1);
return buildFileResponse(
- Path.of(FilePathService.getAttachmentUnitFilePath(), String.valueOf(attachmentUnit.getId()), "slide", String.valueOf(slide.getSlideNumber())).toString(),
- fileName, true);
+ FilePathService.getAttachmentUnitFilePath().resolve(Path.of(attachmentUnit.getId().toString(), "slide", String.valueOf(slide.getSlideNumber()))), fileName,
+ true);
}
else {
throw new EntityNotFoundException("Slide", slideNumber);
@@ -463,20 +467,19 @@ public ResponseEntity getAttachmentUnitAttachmentSlide(@PathVariable Lon
* @param filename the name of the file
* @return response entity
*/
- private ResponseEntity buildFileResponse(String path, String filename) {
+ private ResponseEntity buildFileResponse(Path path, String filename) {
return buildFileResponse(path, filename, false);
}
/**
* Builds the response with headers, body and content type for specified path containing the file name
*
- * @param pathString to the file including the file name
- * @param cache true if the response should contain a header that allows caching; false otherwise
+ * @param path to the file including the file name
+ * @param cache true if the response should contain a header that allows caching; false otherwise
* @return response entity
*/
- private ResponseEntity buildFileResponse(String pathString, boolean cache) {
- Path path = Path.of(pathString);
- return buildFileResponse(path.getParent().toString(), path.getFileName().toString(), cache);
+ private ResponseEntity buildFileResponse(Path path, boolean cache) {
+ return buildFileResponse(path.getParent(), path.getFileName().toString(), cache);
}
/**
@@ -487,10 +490,10 @@ private ResponseEntity buildFileResponse(String pathString, boolean cach
* @param cache true if the response should contain a header that allows caching; false otherwise
* @return response entity
*/
- private ResponseEntity buildFileResponse(String path, String filename, boolean cache) {
+ private ResponseEntity buildFileResponse(Path path, String filename, boolean cache) {
try {
- var actualPath = Path.of(path, filename).toString();
- var file = fileService.getFileForPath(actualPath);
+ Path actualPath = path.resolve(filename);
+ byte[] file = fileService.getFileForPath(actualPath);
if (file == null) {
return ResponseEntity.notFound().build();
}
@@ -547,7 +550,7 @@ private void checkAttachmentAuthorizationOrThrow(Course course, Attachment attac
* @param filePath the path for the file to read
* @return ResponseEntity with status 200 and the file as byte stream, status 404 if the file doesn't exist, or status 500 if there is an error while reading the file
*/
- private ResponseEntity responseEntityForFilePath(String filePath) {
+ private ResponseEntity responseEntityForFilePath(Path filePath) {
try {
var file = fileService.getFileForPath(filePath);
if (file == null) {
@@ -556,7 +559,7 @@ private ResponseEntity responseEntityForFilePath(String filePath) {
return ResponseEntity.ok(file);
}
catch (IOException e) {
- e.printStackTrace();
+ log.error("Failed to return requested file with path {}", filePath, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/admin/AdminCourseResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/admin/AdminCourseResource.java
index c57e0b30b911..dae6f829e55f 100644
--- a/src/main/java/de/tum/in/www1/artemis/web/rest/admin/AdminCourseResource.java
+++ b/src/main/java/de/tum/in/www1/artemis/web/rest/admin/AdminCourseResource.java
@@ -124,7 +124,7 @@ public ResponseEntity createCourse(@RequestPart Course course, @RequestP
courseService.createOrValidateGroups(course);
if (file != null) {
- String pathString = fileService.handleSaveFile(file, false, false);
+ String pathString = fileService.handleSaveFile(file, false, false).toString();
course.setCourseIcon(pathString);
}
diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/repository/RepositoryResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/repository/RepositoryResource.java
index d94088c67748..5cce9e0333c0 100644
--- a/src/main/java/de/tum/in/www1/artemis/web/rest/repository/RepositoryResource.java
+++ b/src/main/java/de/tum/in/www1/artemis/web/rest/repository/RepositoryResource.java
@@ -8,8 +8,6 @@
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileAlreadyExistsException;
-import java.nio.file.Files;
-import java.nio.file.StandardCopyOption;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -17,6 +15,7 @@
import javax.servlet.http.HttpServletRequest;
+import org.apache.commons.io.FileUtils;
import org.eclipse.jgit.api.errors.CheckoutConflictException;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.api.errors.WrongRepositoryStateException;
@@ -391,7 +390,7 @@ private void fetchAndUpdateFile(FileSubmission submission, Repository repository
}
InputStream inputStream = new ByteArrayInputStream(submission.getFileContent().getBytes(StandardCharsets.UTF_8));
- Files.copy(inputStream, file.get().toPath(), StandardCopyOption.REPLACE_EXISTING);
+ FileUtils.copyToFile(inputStream, file.get());
inputStream.close();
}
}
diff --git a/src/main/java/de/tum/in/www1/artemis/web/websocket/QuizSubmissionWebsocketService.java b/src/main/java/de/tum/in/www1/artemis/web/websocket/QuizSubmissionWebsocketService.java
index e394a3863d1f..8aac25aa6091 100644
--- a/src/main/java/de/tum/in/www1/artemis/web/websocket/QuizSubmissionWebsocketService.java
+++ b/src/main/java/de/tum/in/www1/artemis/web/websocket/QuizSubmissionWebsocketService.java
@@ -4,8 +4,6 @@
import javax.validation.Valid;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
@@ -14,13 +12,12 @@
import de.tum.in.www1.artemis.domain.quiz.QuizSubmission;
import de.tum.in.www1.artemis.exception.QuizSubmissionException;
import de.tum.in.www1.artemis.security.SecurityUtils;
-import de.tum.in.www1.artemis.service.*;
+import de.tum.in.www1.artemis.service.QuizSubmissionService;
+import de.tum.in.www1.artemis.service.WebsocketMessagingService;
@Controller
public class QuizSubmissionWebsocketService {
- private static final Logger log = LoggerFactory.getLogger(QuizSubmissionWebsocketService.class);
-
private final QuizSubmissionService quizSubmissionService;
private final WebsocketMessagingService websocketMessagingService;
@@ -45,29 +42,11 @@ public void saveSubmission(@DestinationVariable Long exerciseId, @Valid @Payload
// Without this, custom jpa repository methods don't work in websocket channel.
SecurityUtils.setAuthorizationObject();
try {
- QuizSubmission updatedQuizSubmission = quizSubmissionService.saveSubmissionForLiveMode(exerciseId, quizSubmission, principal.getName(), false);
- // send updated submission over websocket (use a thread to prevent that the outbound channel blocks the inbound channel (e.g. due a slow client))
- // to improve the performance, this is currently deactivated: slow clients might lead to bottlenecks so that more important messages can not be distributed any more
- // new Thread(() -> sendSubmissionToUser(username, exerciseId, quizSubmission)).start();
-
- // log.info("WS.Inbound: Sent quiz submission (async) back to user {} in quiz {} after {} µs ", principal.getName(), exerciseId, (System.nanoTime() - start) / 1000);
+ quizSubmissionService.saveSubmissionForLiveMode(exerciseId, quizSubmission, principal.getName(), false);
}
catch (QuizSubmissionException ex) {
// send error message over websocket (use Async to prevent that the outbound channel blocks the inbound channel (e.g. due a slow client))
websocketMessagingService.sendMessageToUser(principal.getName(), "/topic/quizExercise/" + exerciseId + "/submission", new WebsocketError(ex.getMessage()));
}
}
-
- /**
- * Should be invoked using a thread asynchronously
- *
- * @param username the user who saved / submitted the quiz submission
- * @param exerciseId the quiz exercise id
- * @param quizSubmission the quiz submission that is returned back to the user
- */
- private void sendSubmissionToUser(String username, Long exerciseId, QuizSubmission quizSubmission) {
- long start = System.nanoTime();
- websocketMessagingService.sendMessageToUser(username, "/topic/quizExercise/" + exerciseId + "/submission", quizSubmission);
- log.info("WS.Outbound: Sent quiz submission to user {} in quiz {} in {} µs ", username, exerciseId, (System.nanoTime() - start) / 1000);
- }
}
diff --git a/src/main/webapp/app/core/about-us/about-us.component.ts b/src/main/webapp/app/core/about-us/about-us.component.ts
index 34ba2b455a03..1d6fb1b5fd58 100644
--- a/src/main/webapp/app/core/about-us/about-us.component.ts
+++ b/src/main/webapp/app/core/about-us/about-us.component.ts
@@ -41,6 +41,7 @@ export class AboutUsComponent implements OnInit {
['learningAnalytics', { learningAnalyticsUrl: 'https://ls1intum.github.io/Artemis/user/learning-analytics/' }],
['adaptiveLearning', { adaptiveLearningUrl: 'https://ls1intum.github.io/Artemis/user/adaptive-learning/' }],
['tutorialGroups', { tutorialGroupsUrl: 'https://ls1intum.github.io/Artemis/user/tutorialgroups/' }],
+ ['iris', { irisUrl: 'https://artemis.cit.tum.de/about-iris' }],
['scalable', { scalingUrl: 'https://ls1intum.github.io/Artemis/user/scaling/' }],
['highUserSatisfaction', { userExperienceUrl: 'https://ls1intum.github.io/Artemis/user/user-experience/' }],
['customizable', { customizableUrl: 'https://ls1intum.github.io/Artemis/user/courses/customizable' }],
diff --git a/src/main/webapp/app/course/manage/course-lti-configuration/course-lti-configuration.component.ts b/src/main/webapp/app/course/manage/course-lti-configuration/course-lti-configuration.component.ts
index bcb3d0e29ed7..47f8b19703e4 100644
--- a/src/main/webapp/app/course/manage/course-lti-configuration/course-lti-configuration.component.ts
+++ b/src/main/webapp/app/course/manage/course-lti-configuration/course-lti-configuration.component.ts
@@ -72,7 +72,7 @@ export class CourseLtiConfigurationComponent implements OnInit {
* Gets the deep linking url
*/
getDeepLinkingUrl(): string {
- return `${location.origin}/api/lti13/deep-linking/${this.course.id}`; // Needs to match url in CustomLti13Configurer
+ return `${location.origin}/api/public/lti13/deep-linking/${this.course.id}`; // Needs to match url in CustomLti13Configurer
}
/**
@@ -93,14 +93,14 @@ export class CourseLtiConfigurationComponent implements OnInit {
* Gets the initiate login url
*/
getInitiateLoginUrl(): string {
- return `${location.origin}/api/lti13/initiate-login/${this.onlineCourseConfiguration?.registrationId}`; // Needs to match uri in CustomLti13Configurer
+ return `${location.origin}/api/public/lti13/initiate-login/${this.onlineCourseConfiguration?.registrationId}`; // Needs to match uri in CustomLti13Configurer
}
/**
* Gets the redirect uri
*/
getRedirectUri(): string {
- return `${location.origin}/api/lti13/auth-callback`; // Needs to match uri in CustomLti13Configurer
+ return `${location.origin}/api/public/lti13/auth-callback`; // Needs to match uri in CustomLti13Configurer
}
/**
diff --git a/src/main/webapp/app/entities/exercise.model.ts b/src/main/webapp/app/entities/exercise.model.ts
index 11474da66052..286e89a58bef 100644
--- a/src/main/webapp/app/entities/exercise.model.ts
+++ b/src/main/webapp/app/entities/exercise.model.ts
@@ -152,6 +152,10 @@ export abstract class Exercise implements BaseEntity {
}
}
+/**
+ * Get an icon for the type of the given exercise.
+ * @param exerciseType {ExerciseType}
+ */
export function getIcon(exerciseType?: ExerciseType): IconProp {
if (!exerciseType) {
return faQuestion as IconProp;
diff --git a/src/main/webapp/app/exam/manage/exams/exam-exercise-import/exam-exercise-import.component.html b/src/main/webapp/app/exam/manage/exams/exam-exercise-import/exam-exercise-import.component.html
index 087c41cd6df0..f4c2bade4233 100644
--- a/src/main/webapp/app/exam/manage/exams/exam-exercise-import/exam-exercise-import.component.html
+++ b/src/main/webapp/app/exam/manage/exams/exam-exercise-import/exam-exercise-import.component.html
@@ -90,7 +90,7 @@
+ * It captures the logs for each test group separately and prints them to the console at the end of the test class.
+ * The test group is determined by the test's class. Each test class can only be assigned to one test group.
+ * If a log cannot be assigned to a test group, the logs are printed for all active test groups.
+ */
+public class ParallelConsoleAppender extends AppenderBase {
+
+ private PatternLayoutEncoder encoder;
+
+ private static final InheritableThreadLocal> LOCAL_TEST_GROUP = new InheritableThreadLocal<>();
+
+ private static final ConcurrentMap, ByteArrayOutputStream> TEST_GROUP_TO_ENCODED_LOGS = new ConcurrentHashMap<>();
+
+ private static final Set> TEST_GROUPS = Set.of(AbstractSpringIntegrationBambooBitbucketJiraTest.class, AbstractSpringIntegrationGitlabCIGitlabSamlTest.class,
+ AbstractSpringIntegrationJenkinsGitlabTest.class, AbstractSpringIntegrationLocalCILocalVCTest.class, AbstractSpringIntegrationIndependentTest.class);
+
+ @Override
+ protected synchronized void append(ILoggingEvent loggingEvent) {
+ Class> testClass = LOCAL_TEST_GROUP.get();
+
+ // Add the logging Event to the corresponding List in the Map
+ if (testClass != null && !loggingEvent.getThreadName().contains("event")) {
+ if (TEST_GROUP_TO_ENCODED_LOGS.containsKey(testClass)) {
+ TEST_GROUP_TO_ENCODED_LOGS.get(testClass).writeBytes(encoder.encode(loggingEvent));
+ }
+ else {
+ ByteArrayOutputStream logs = new ByteArrayOutputStream();
+ logs.writeBytes(encoder.encode(loggingEvent));
+ TEST_GROUP_TO_ENCODED_LOGS.put(testClass, logs);
+ }
+ return;
+ }
+
+ // If the thread id is not assigned to a TestGroup, we add the logging event for all active TestGroups
+ for (ByteArrayOutputStream logs : TEST_GROUP_TO_ENCODED_LOGS.values()) {
+ logs.writeBytes(encoder.encode(loggingEvent));
+ }
+ }
+
+ /**
+ * Prints the logs for the given test group to the console and removes them.
+ * This method should be called at the end of a test class.
+ *
+ * @param testClass the test's class for which the logs should be printed
+ */
+ public static synchronized void printLogsForGroup(Class> testClass) {
+ Class> testGroupClass = groupFromClass(testClass);
+ ByteArrayOutputStream logs = TEST_GROUP_TO_ENCODED_LOGS.remove(testGroupClass);
+ if (logs == null) {
+ return;
+ }
+
+ System.out.writeBytes(logs.toByteArray());
+ logs.reset();
+ System.out.flush();
+ }
+
+ /**
+ * Adds the given string to the logs for the current test group.
+ *
+ * @param string the string to add to the logs
+ */
+ public static synchronized void addStringToLogsForGroup(String string) {
+ Class> testClass = LOCAL_TEST_GROUP.get();
+
+ // Add the logging Event to the corresponding List in the Map
+ if (testClass != null) {
+ if (TEST_GROUP_TO_ENCODED_LOGS.containsKey(testClass)) {
+ TEST_GROUP_TO_ENCODED_LOGS.get(testClass).writeBytes(string.getBytes());
+ }
+ else {
+ ByteArrayOutputStream logs = new ByteArrayOutputStream();
+ logs.writeBytes(string.getBytes());
+ TEST_GROUP_TO_ENCODED_LOGS.put(testClass, logs);
+ }
+ return;
+ }
+
+ // If the thread id is not assigned to a TestGroup, we add the logging event for all active TestGroups
+ for (ByteArrayOutputStream logs : TEST_GROUP_TO_ENCODED_LOGS.values()) {
+ logs.writeBytes(string.getBytes());
+ }
+
+ }
+
+ /**
+ * Registers the test group for the given test class.
+ * This method should be called at the beginning of a test class.
+ *
+ * @param testClass the test's class
+ */
+ public static void registerActiveTestGroup(Class> testClass) {
+ LOCAL_TEST_GROUP.set(groupFromClass(testClass));
+ }
+
+ /**
+ * Unregisters the test group for the given test class.
+ * This method should be called at the end of a test class.
+ *
+ * @param testClass the test's class
+ */
+ public static void unregisterActiveTestGroup(Class> testClass) {
+ TEST_GROUP_TO_ENCODED_LOGS.remove(testClass);
+ LOCAL_TEST_GROUP.remove();
+ }
+
+ /**
+ * Sets the encoder for this appender. This method is used by logback and should not be removed.
+ * This method is used to set the encoder's pattern in the logback.xml file.
+ *
+ * @param encoder the encoder used to encode the logging events
+ */
+ @SuppressWarnings("unused")
+ public void setEncoder(PatternLayoutEncoder encoder) {
+ this.encoder = encoder;
+ }
+
+ /**
+ * Returns the test group's class for the given class.
+ * If none of the groups' classes is assignable from the given class, the class itself is returned.
+ *
+ * @param clazz the class for which the test group's class should be returned
+ * @return the test group's class for the given class
+ */
+ private static Class> groupFromClass(Class> clazz) {
+ if (clazz == null) {
+ return null;
+ }
+
+ for (Class> group : TEST_GROUPS) {
+ if (group.isAssignableFrom(clazz)) {
+ return group;
+ }
+ }
+
+ if (AbstractArtemisIntegrationTest.class.isAssignableFrom(clazz)) {
+ fail("Test class " + clazz.getName() + " extends ArtemisIntegrationTest but is not assigned to a test group");
+ }
+
+ return clazz;
+ }
+}
diff --git a/src/test/java/de/tum/in/www1/artemis/util/junit_parallel_logging/ThreadIdConverter.java b/src/test/java/de/tum/in/www1/artemis/util/junit_parallel_logging/ThreadIdConverter.java
new file mode 100644
index 000000000000..cfd9d1653db7
--- /dev/null
+++ b/src/test/java/de/tum/in/www1/artemis/util/junit_parallel_logging/ThreadIdConverter.java
@@ -0,0 +1,17 @@
+package de.tum.in.www1.artemis.util.junit_parallel_logging;
+
+import ch.qos.logback.classic.pattern.ClassicConverter;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+
+/**
+ * A custom Logback converter that can be used to display the thread id in the logs.
+ *
+ * This converter is used to distinguish logs from different threads when running tests in parallel.
+ */
+public class ThreadIdConverter extends ClassicConverter {
+
+ @Override
+ public String convert(ILoggingEvent iLoggingEvent) {
+ return String.valueOf(Thread.currentThread().getId());
+ }
+}
diff --git a/src/test/java/de/tum/in/www1/artemis/web/rest/AndroidAppSiteAssociationResourceTest.java b/src/test/java/de/tum/in/www1/artemis/web/rest/AndroidAppSiteAssociationResourceTest.java
index 3798e8fb431e..6762f1ab3503 100644
--- a/src/test/java/de/tum/in/www1/artemis/web/rest/AndroidAppSiteAssociationResourceTest.java
+++ b/src/test/java/de/tum/in/www1/artemis/web/rest/AndroidAppSiteAssociationResourceTest.java
@@ -7,9 +7,9 @@
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
-import de.tum.in.www1.artemis.AbstractSpringIntegrationBambooBitbucketJiraTest;
+import de.tum.in.www1.artemis.AbstractSpringIntegrationIndependentTest;
-class AndroidAppSiteAssociationResourceTest extends AbstractSpringIntegrationBambooBitbucketJiraTest {
+class AndroidAppSiteAssociationResourceTest extends AbstractSpringIntegrationIndependentTest {
@Autowired
AndroidAppSiteAssociationResource androidAppSiteAssociationResource;
diff --git a/src/test/java/de/tum/in/www1/artemis/web/rest/AppleAppSiteAssociationResourceTest.java b/src/test/java/de/tum/in/www1/artemis/web/rest/AppleAppSiteAssociationResourceTest.java
index 74ad5fe60bea..fc89211686d4 100644
--- a/src/test/java/de/tum/in/www1/artemis/web/rest/AppleAppSiteAssociationResourceTest.java
+++ b/src/test/java/de/tum/in/www1/artemis/web/rest/AppleAppSiteAssociationResourceTest.java
@@ -5,9 +5,9 @@
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
-import de.tum.in.www1.artemis.AbstractSpringIntegrationBambooBitbucketJiraTest;
+import de.tum.in.www1.artemis.AbstractSpringIntegrationIndependentTest;
-class AppleAppSiteAssociationResourceTest extends AbstractSpringIntegrationBambooBitbucketJiraTest {
+class AppleAppSiteAssociationResourceTest extends AbstractSpringIntegrationIndependentTest {
@Autowired
AppleAppSiteAssociationResource appleAppSiteAssociationResource;
diff --git a/src/test/javascript/spec/component/course/course-lti-configuration.component.spec.ts b/src/test/javascript/spec/component/course/course-lti-configuration.component.spec.ts
index 5848c452146d..83bc1e617eb7 100644
--- a/src/test/javascript/spec/component/course/course-lti-configuration.component.spec.ts
+++ b/src/test/javascript/spec/component/course/course-lti-configuration.component.spec.ts
@@ -117,11 +117,11 @@ describe('Course LTI Configuration Component', () => {
expect(findWithExercisesStub).toHaveBeenCalledOnce();
expect(comp.getDynamicRegistrationUrl()).toBe(`${location.origin}/lti/dynamic-registration/${course.id}`);
- expect(comp.getDeepLinkingUrl()).toBe(`${location.origin}/api/lti13/deep-linking/${course.id}`);
+ expect(comp.getDeepLinkingUrl()).toBe(`${location.origin}/api/public/lti13/deep-linking/${course.id}`);
expect(comp.getToolUrl()).toBe(`${location.origin}/courses/${course.id}`);
expect(comp.getKeysetUrl()).toBe(`${location.origin}/.well-known/jwks.json`);
- expect(comp.getInitiateLoginUrl()).toBe(`${location.origin}/api/lti13/initiate-login/${course.onlineCourseConfiguration?.registrationId}`);
- expect(comp.getRedirectUri()).toBe(`${location.origin}/api/lti13/auth-callback`);
+ expect(comp.getInitiateLoginUrl()).toBe(`${location.origin}/api/public/lti13/initiate-login/${course.onlineCourseConfiguration?.registrationId}`);
+ expect(comp.getRedirectUri()).toBe(`${location.origin}/api/public/lti13/auth-callback`);
});
});
diff --git a/src/test/javascript/spec/component/exam/manage/exam-exercise-import.component.spec.ts b/src/test/javascript/spec/component/exam/manage/exam-exercise-import.component.spec.ts
index ce6488144644..31aa300beba3 100644
--- a/src/test/javascript/spec/component/exam/manage/exam-exercise-import.component.spec.ts
+++ b/src/test/javascript/spec/component/exam/manage/exam-exercise-import.component.spec.ts
@@ -293,11 +293,11 @@ describe('Exam Exercise Import Component', () => {
});
it('should correctly return the Exercise Icon', () => {
- expect(component.getExerciseIcon(modelingExercise)).toEqual(faProjectDiagram);
- expect(component.getExerciseIcon(textExercise)).toEqual(faFont);
- expect(component.getExerciseIcon(programmingExercise)).toEqual(faKeyboard);
- expect(component.getExerciseIcon(quizExercise)).toEqual(faCheckDouble);
- expect(component.getExerciseIcon(fileUploadExercise)).toEqual(faFileUpload);
+ expect(component.getExerciseIcon(modelingExercise.type)).toEqual(faProjectDiagram);
+ expect(component.getExerciseIcon(textExercise.type)).toEqual(faFont);
+ expect(component.getExerciseIcon(programmingExercise.type)).toEqual(faKeyboard);
+ expect(component.getExerciseIcon(quizExercise.type)).toEqual(faCheckDouble);
+ expect(component.getExerciseIcon(fileUploadExercise.type)).toEqual(faFileUpload);
});
describe('Programming exercise import validation', () => {
diff --git a/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts b/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts
index 1d756fbe3eb7..d6a84e31ae72 100644
--- a/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts
+++ b/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts
@@ -90,7 +90,7 @@ describe('Lti13ExerciseLaunchComponent', () => {
expect(consoleSpy).toHaveBeenCalledOnce();
expect(consoleSpy).toHaveBeenCalledWith('No LTI targetLinkUri received for a successful launch');
expect(httpStub).toHaveBeenCalledOnce();
- expect(httpStub).toHaveBeenCalledWith('api/lti13/auth-login', expect.anything(), expect.anything());
+ expect(httpStub).toHaveBeenCalledWith('api/public/lti13/auth-login', expect.anything(), expect.anything());
expect(comp.isLaunching).toBeFalse();
});
@@ -104,7 +104,7 @@ describe('Lti13ExerciseLaunchComponent', () => {
comp.ngOnInit();
expect(httpStub).toHaveBeenCalledOnce();
- expect(httpStub).toHaveBeenCalledWith('api/lti13/auth-login', expect.anything(), expect.anything());
+ expect(httpStub).toHaveBeenCalledWith('api/public/lti13/auth-login', expect.anything(), expect.anything());
});
it('onInit launch fails on error', () => {
@@ -118,7 +118,7 @@ describe('Lti13ExerciseLaunchComponent', () => {
comp.ngOnInit();
expect(httpStub).toHaveBeenCalledOnce();
- expect(httpStub).toHaveBeenCalledWith('api/lti13/auth-login', expect.anything(), expect.anything());
+ expect(httpStub).toHaveBeenCalledWith('api/public/lti13/auth-login', expect.anything(), expect.anything());
expect(comp.isLaunching).toBeFalse();
});
diff --git a/src/test/javascript/spec/component/participation-submission/participation-submission.component.spec.ts b/src/test/javascript/spec/component/participation-submission/participation-submission.component.spec.ts
index a29c93177975..87c6053f53f8 100644
--- a/src/test/javascript/spec/component/participation-submission/participation-submission.component.spec.ts
+++ b/src/test/javascript/spec/component/participation-submission/participation-submission.component.spec.ts
@@ -172,6 +172,7 @@ describe('ParticipationSubmissionComponent', () => {
expect(findAllSubmissionsOfParticipationStub).toHaveBeenCalledOnce();
expect(comp.participation).toEqual(participation);
expect(comp.submissions).toEqual(submissions);
+ expect(comp.participation?.submissions).toEqual(submissions);
// check if delete button is available
const deleteButton = debugElement.query(By.css('#deleteButton'));
diff --git a/src/test/javascript/spec/component/programming-exercise/programming-exercise.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/programming-exercise.component.spec.ts
index ccaa5888bf1a..7915f67f8f84 100644
--- a/src/test/javascript/spec/component/programming-exercise/programming-exercise.component.spec.ts
+++ b/src/test/javascript/spec/component/programming-exercise/programming-exercise.component.spec.ts
@@ -116,6 +116,29 @@ describe('ProgrammingExercise Management Component', () => {
expect(mockSubscriber).toHaveBeenCalledOnce();
});
+ it('should delete multiple exercises', () => {
+ const headers = new HttpHeaders().append('link', 'link;link');
+ jest.spyOn(programmingExerciseService, 'delete').mockReturnValue(
+ of(
+ new HttpResponse({
+ body: {},
+ headers,
+ }),
+ ),
+ );
+ const mockSubscriber = jest.fn();
+ comp.dialogError$.subscribe(mockSubscriber);
+
+ comp.course = course;
+ comp.ngOnInit();
+ comp.deleteMultipleProgrammingExercises([{ id: 441 }, { id: 442 }, { id: 443 }] as ProgrammingExercise[], {
+ deleteStudentReposBuildPlans: true,
+ deleteBaseReposBuildPlans: true,
+ });
+ expect(programmingExerciseService.delete).toHaveBeenCalledTimes(3);
+ expect(mockSubscriber).toHaveBeenCalledTimes(3);
+ });
+
it('should not delete exercise on error', () => {
const httpErrorResponse = new HttpErrorResponse({ error: 'Forbidden', status: 403 });
jest.spyOn(programmingExerciseService, 'delete').mockReturnValue(throwError(() => httpErrorResponse));
@@ -201,41 +224,41 @@ describe('ProgrammingExercise Management Component', () => {
describe('ProgrammingExercise Select Exercises', () => {
it('should add selected exercise to list', () => {
// WHEN
- comp.toggleProgrammingExercise(programmingExercise);
+ comp.toggleExercise(programmingExercise);
// THEN
- expect(comp.selectedProgrammingExercises[0]).toContainEntry(['id', programmingExercise.id]);
+ expect(comp.selectedExercises[0]).toContainEntry(['id', programmingExercise.id]);
});
it('should remove selected exercise to list', () => {
// WHEN
- comp.toggleProgrammingExercise(programmingExercise);
- comp.toggleProgrammingExercise(programmingExercise);
+ comp.toggleExercise(programmingExercise);
+ comp.toggleExercise(programmingExercise);
// THEN
- expect(comp.selectedProgrammingExercises).toHaveLength(0);
+ expect(comp.selectedExercises).toHaveLength(0);
});
it('should select all', () => {
// WHEN
- comp.toggleAllProgrammingExercises();
+ comp.toggleMultipleExercises(comp.programmingExercises);
// THEN
- expect(comp.selectedProgrammingExercises).toHaveLength(comp.programmingExercises.length);
+ expect(comp.selectedExercises).toHaveLength(comp.programmingExercises.length);
});
it('should deselect all', () => {
// WHEN
- comp.toggleAllProgrammingExercises(); // Select all
- comp.toggleAllProgrammingExercises(); // Deselect all
+ comp.toggleMultipleExercises(comp.programmingExercises); // Select all
+ comp.toggleMultipleExercises(comp.programmingExercises); // Deselect all
// THEN
- expect(comp.selectedProgrammingExercises).toHaveLength(0);
+ expect(comp.selectedExercises).toHaveLength(0);
});
it('should check correctly if selected', () => {
// WHEN
- comp.toggleProgrammingExercise(programmingExercise);
+ comp.toggleExercise(programmingExercise);
// THEN
expect(comp.isExerciseSelected(programmingExercise)).toBeTrue();
diff --git a/src/test/javascript/spec/component/quiz-exercise/quiz-exercise.component.spec.ts b/src/test/javascript/spec/component/quiz-exercise/quiz-exercise.component.spec.ts
index 374e9d692206..a2944fbe9a88 100644
--- a/src/test/javascript/spec/component/quiz-exercise/quiz-exercise.component.spec.ts
+++ b/src/test/javascript/spec/component/quiz-exercise/quiz-exercise.component.spec.ts
@@ -281,6 +281,22 @@ describe('QuizExercise Management Component', () => {
expect(quizExerciseService.delete).toHaveBeenCalledOnce();
});
+ it('should delete multiple quizzes', () => {
+ const headers = new HttpHeaders().append('link', 'link;link');
+ jest.spyOn(quizExerciseService, 'delete').mockReturnValue(
+ of(
+ new HttpResponse({
+ body: {},
+ headers,
+ }),
+ ),
+ );
+
+ comp.ngOnInit();
+ comp.deleteMultipleExercises([{ id: 1 }, { id: 2 }, { id: 3 }] as QuizExercise[], comp.quizExerciseService);
+ expect(quizExerciseService.delete).toHaveBeenCalledTimes(3);
+ });
+
it('should export quiz', () => {
const headers = new HttpHeaders().append('link', 'link;link');
jest.spyOn(quizExerciseService, 'find').mockReturnValue(
diff --git a/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension
index 7fc53e343616..8dfc3c5b68c1 100644
--- a/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension
+++ b/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension
@@ -1 +1,2 @@
de.tum.in.www1.artemis.util.junit_extensions.AwaitilityExtension
+de.tum.in.www1.artemis.util.junit_extensions.ParallelLoggingExtension
diff --git a/src/test/resources/junit-platform.properties b/src/test/resources/junit-platform.properties
index bbcb24682b30..a2754a0609bb 100644
--- a/src/test/resources/junit-platform.properties
+++ b/src/test/resources/junit-platform.properties
@@ -1,6 +1,8 @@
-#junit.jupiter.execution.parallel.enabled = true
-#junit.jupiter.execution.parallel.mode.default = concurrent
-#junit.jupiter.execution.parallel.mode.classes.default = same_thread
+# Enables junit5 parallel test execution. Tests are run on one JVM instance.
+junit.jupiter.execution.parallel.enabled = true
+
+# Enables ordering test-classes with JUnit5 by class name.
+junit.jupiter.testclass.order.default = org.junit.jupiter.api.ClassOrderer$ClassName
# Enables JUnit5 automatic detection of extensions.
junit.jupiter.extensions.autodetection.enabled = true
diff --git a/src/test/resources/logback.xml b/src/test/resources/logback.xml
index 0adc98c942a1..f51816ce443b 100644
--- a/src/test/resources/logback.xml
+++ b/src/test/resources/logback.xml
@@ -2,7 +2,15 @@
-
+
+
+
+
+
+
+ %16.16d{HH:mm:ss.SSS} | %3.3threadId %-16.16thread | %-5level | %-36.36logger{36} : %msg%n
+
+
@@ -37,7 +45,7 @@
WARN
-
+
diff --git a/supporting_scripts/flaky_test_detection.sh b/supporting_scripts/flaky_test_detection.sh
new file mode 100644
index 000000000000..ec941b0b240e
--- /dev/null
+++ b/supporting_scripts/flaky_test_detection.sh
@@ -0,0 +1,83 @@
+#!/bin/bash
+
+# Check for the number of test runs argument.
+if [ $# -eq 0 ]; then
+ echo "Usage: $0 "
+ exit 1
+fi
+
+# Get the number of test runs from the command line argument.
+NUM_RUNS="$1"
+
+# Define spring profiles.
+SPRING_PROFILES=("none" "mysql" "postgres")
+
+DIRECTORY='./build/flaky-test-detection-results'
+
+# Create the directory, if it doesn't exist.
+mkdir -p ${DIRECTORY}
+
+# ==================== #
+# FLAKY TEST DETECTION #
+# ==================== #
+
+for ((run = 1; run <= NUM_RUNS; run++)); do
+ # Generate a random number between 1 and 13.
+ spring_profile_chance=$((RANDOM % 13 + 1))
+
+ # Determine the active spring profile based on the random number:
+ # 10/13 chance of no profile, 2/13 chance of MYSQL, 1/13 chance of POSTGRES.
+ # (Should result in similar execution times for each profile).
+ profile_index=0
+ if [[ $spring_profile_chance -gt 10 ]]; then
+ profile_index=1
+ fi
+
+ if [[ $spring_profile_chance -gt 12 ]]; then
+ profile_index=2
+ fi
+ active_profile="${SPRING_PROFILES[$profile_index]}"
+
+ # Generate output file name
+ TIME=$(date +"%Y-%m-%d_%H:%M:%S")
+ output_file="${active_profile}_${TIME}_run${run}.log"
+
+ # Run tests with gradlew
+ echo "Running tests with Spring Profile: $active_profile (Run $run)"
+ set -o pipefail && SPRING_PROFILES_INCLUDE="$active_profile" ./gradlew --console=plain \
+ test --rerun jacocoTestReport -x webapp jacocoTestCoverageVerification > "${DIRECTORY}/$output_file"
+
+ # Check if tests were successful. If not, rename the output file to indicate failure. Delete the output file if tests were successful.
+ if grep -q "BUILD SUCCESSFUL" "${DIRECTORY}/$output_file"; then
+ rm "${DIRECTORY}/$output_file"
+ else
+ mv "${DIRECTORY}/$output_file" "${DIRECTORY}/FAILURE_${output_file}"
+ fi
+
+ # Wait a bit before the next run
+ sleep 10
+done
+
+# ================== #
+# FLAKY TEST SUMMARY #
+# ================== #
+
+echo "Generating flaky test summary..."
+
+SUMMARY_DIRECTORY="${DIRECTORY}/summary"
+mkdir -p "$SUMMARY_DIRECTORY"
+
+SUMMARY_FILE="${SUMMARY_DIRECTORY}/run-summary.txt"
+printf "Logfile and Failed Tests\n" > "$SUMMARY_FILE"
+for file in "$DIRECTORY"/*.log; do
+ if [ -f "$file" ]; then
+ printf "\nFailed tests in $(basename "$file"):\n" >> "$SUMMARY_FILE"
+ grep "Test >.* FAILED" "$file" >> "$SUMMARY_FILE"
+ fi
+done
+
+COUNT_FILE="${SUMMARY_DIRECTORY}/failure-count.txt"
+printf "Count of Failed Tests\n" > "$COUNT_FILE"
+grep "Test >.* FAILED" "$SUMMARY_FILE" | sort | uniq -c | sort -nr >> "$COUNT_FILE"
+
+echo "Tests completed for $NUM_RUNS run(s)."