summaryrefslogtreecommitdiff
path: root/lib/gitlab/lfs/client.rb
blob: a05e8107cadf4f923602574050bd847e34209bae (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# frozen_string_literal: true
module Gitlab
  module Lfs
    # Gitlab::Lfs::Client implements a simple LFS client, designed to talk to
    # LFS servers as described in these documents:
    #   * https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md
    #   * https://github.com/git-lfs/git-lfs/blob/master/docs/api/basic-transfers.md
    class Client
      GIT_LFS_CONTENT_TYPE = 'application/vnd.git-lfs+json'
      GIT_LFS_USER_AGENT = "GitLab #{Gitlab::VERSION} LFS client"
      DEFAULT_HEADERS = {
        'Accept' => GIT_LFS_CONTENT_TYPE,
        'Content-Type' => GIT_LFS_CONTENT_TYPE,
        'User-Agent' => GIT_LFS_USER_AGENT
      }.freeze

      attr_reader :base_url

      def initialize(base_url, credentials:)
        @base_url = base_url
        @credentials = credentials
      end

      def batch!(operation, objects)
        body = {
          operation: operation,
          transfers: ['basic'],
          # We don't know `ref`, so can't send it
          objects: objects.as_json(only: [:oid, :size])
        }

        rsp = Gitlab::HTTP.post(
          batch_url,
          basic_auth: basic_auth,
          body: body.to_json,
          headers: build_request_headers
        )

        raise BatchSubmitError unless rsp.success?

        # HTTParty provides rsp.parsed_response, but it only kicks in for the
        # application/json content type in the response, which we can't rely on
        body = Gitlab::Json.parse(rsp.body)
        transfer = body.fetch('transfer', 'basic')

        raise UnsupportedTransferError, transfer.inspect unless transfer == 'basic'

        body
      end

      def upload!(object, upload_action, authenticated:)
        file = object.file.open

        params = {
          body_stream: file,
          headers: {
            'Content-Length' => object.size.to_s,
            'Content-Type' => 'application/octet-stream',
            'User-Agent' => GIT_LFS_USER_AGENT
          }.merge(upload_action['header'] || {})
        }

        authenticated = true if params[:headers].key?('Authorization')
        params[:basic_auth] = basic_auth unless authenticated

        rsp = Gitlab::HTTP.put(upload_action['href'], params)

        raise ObjectUploadError unless rsp.success?
      ensure
        file&.close
      end

      def verify!(object, verify_action, authenticated:)
        params = {
          body: object.to_json(only: [:oid, :size]),
          headers: build_request_headers(verify_action['header'])
        }

        authenticated = true if params[:headers].key?('Authorization')
        params[:basic_auth] = basic_auth unless authenticated

        rsp = Gitlab::HTTP.post(verify_action['href'], params)

        raise ObjectVerifyError unless rsp.success?
      end

      private

      def build_request_headers(extra_headers = nil)
        DEFAULT_HEADERS.merge(extra_headers || {})
      end

      attr_reader :credentials

      def batch_url
        base_url + '/info/lfs/objects/batch'
      end

      def basic_auth
        # Some legacy credentials have a nil auth_method, which means password
        # https://gitlab.com/gitlab-org/gitlab/-/issues/328674
        return unless credentials.fetch(:auth_method, 'password') == 'password'
        return if credentials.empty?

        { username: credentials[:user], password: credentials[:password] }
      end

      class BatchSubmitError < StandardError
        def message
          "Failed to submit batch"
        end
      end

      class UnsupportedTransferError < StandardError
        def initialize(transfer = nil)
          super
          @transfer = transfer
        end

        def message
          "Unsupported transfer: #{@transfer}"
        end
      end

      class ObjectUploadError < StandardError
        def message
          "Failed to upload object"
        end
      end

      class ObjectVerifyError < StandardError
        def message
          "Failed to verify object"
        end
      end
    end
  end
end