diff --git a/doc/api/projects.md b/doc/api/projects.md index b33b5dec3cd7b2e3209cbf40736e7c04c0250a6e..f43f349764fdeb854cb714165aa73a7fbade76f1 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -2639,6 +2639,27 @@ Returned object: } ``` +## Download a project avatar + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/144039) in GitLab 16.9. + +Get a project avatar. +You can access this endpoint without authentication if the project is publicly accessible. + +```plaintext +GET /projects/:id/avatar +``` + +| Attribute | Type | Required | Description | +| --------- | ----------------- | -------- | --------------------- | +| `id` | integer or string | yes | ID or [URL-encoded path](rest/index.md#namespaced-path-encoding) of the project. | + +Example: + +```shell +curl --header "PRIVATE-TOKEN: " "https://gitlab.example.com/api/v4/projects/4/avatar" +``` + ## Remove a project avatar > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/92604) in GitLab 15.4. diff --git a/lib/api/api.rb b/lib/api/api.rb index 00d7425cc75050b01286d20930212c42dc65d52a..9982051d6954bb9ed30590690ae6d484c1f0f91d 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -297,6 +297,7 @@ def initialize(location_url) mount ::API::PagesDomains mount ::API::PersonalAccessTokens::SelfInformation mount ::API::PersonalAccessTokens + mount ::API::ProjectAvatar mount ::API::ProjectClusters mount ::API::ProjectContainerRepositories mount ::API::ProjectDebianDistributions diff --git a/lib/api/project_avatar.rb b/lib/api/project_avatar.rb new file mode 100644 index 0000000000000000000000000000000000000000..e674169bba70d78cff20ade3010346a24399f56b --- /dev/null +++ b/lib/api/project_avatar.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module API + class ProjectAvatar < ::API::Base + feature_category :groups_and_projects + + params do + requires :id, types: [String, Integer], desc: 'ID or URL-encoded path of the project' + end + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + desc 'Download a project avatar' do + detail 'This feature was introduced in GitLab 16.9' + tags %w[project_avatar] + success code: 200 + end + get ':id/avatar' do + avatar = user_project.avatar + + not_found!('Avatar') if avatar.blank? + + header( + 'Content-Disposition', + ActionDispatch::Http::ContentDisposition.format( + disposition: 'attachment', + filename: avatar.filename + ) + ) + + present_carrierwave_file!(avatar) + end + end + end +end diff --git a/spec/requests/api/project_avatar_spec.rb b/spec/requests/api/project_avatar_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e677a3728c90eb977ae122e6d7df4ed4997d41c5 --- /dev/null +++ b/spec/requests/api/project_avatar_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::ProjectAvatar, feature_category: :groups_and_projects do + def avatar_path(project) + "/projects/#{ERB::Util.url_encode(project.full_path)}/avatar" + end + + describe 'GET /projects/:id/avatar' do + context 'when the project is public' do + let(:project) { create(:project, :public, :with_avatar) } + + it 'retrieves the avatar successfully' do + get api(avatar_path(project)) + + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers['Content-Disposition']) + .to eq(%(attachment; filename="dk.png"; filename*=UTF-8''dk.png)) + end + + context 'when the avatar is in object storage' do + before do + stub_uploads_object_storage(AvatarUploader) + + project.avatar.migrate!(ObjectStorage::Store::REMOTE) + end + + it 'redirects to the file in object storage' do + get api(avatar_path(project)) + + expect(response).to have_gitlab_http_status(:found) + expect(response.headers['Content-Disposition']) + .to eq(%(attachment; filename="dk.png"; filename*=UTF-8''dk.png)) + end + end + + context 'when the project does not have an avatar' do + let(:project) { create(:project, :public) } + + it 'returns :not_found' do + get api(avatar_path(project)) + + expect(response).to have_gitlab_http_status(:not_found) + expect(response.body).to eq(%({"message":"404 Avatar Not Found"})) + end + end + + context 'when the project is in a group' do + let(:project) { create(:project, :in_group, :public, :with_avatar) } + + it 'returns :ok' do + get api(avatar_path(project)) + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'when the project is in a subgroup' do + let(:project) { create(:project, :in_subgroup, :public, :with_avatar) } + + it 'returns :ok' do + get api(avatar_path(project)) + + expect(response).to have_gitlab_http_status(:ok) + end + end + end + + context 'when the project is private' do + let(:project) { create(:project, :private, :with_avatar) } + + context 'when the user is not authenticated' do + it 'returns :not_found' do + get api(avatar_path(project)) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when the project user is authenticated' do + context 'and have access to the project' do + let(:owner) { create(:user) } + + before do + project.add_owner(owner) + end + + it 'retrieves the avatar successfully' do + get api(avatar_path(project), owner) + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'and does not have access to the project' do + it 'returns :not_found' do + get api(avatar_path(project), create(:user)) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + end + end +end