An unofficial, mostly Bitwarden-compatible API server written in Ruby (Sinatra and ActiveRecord)
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Adding support for attachments (#65)

authored by

Johannes / universa1 and committed by
joshua stein
ce9d0c42 287ebc72

+374 -27
+56
API.md
··· 592 592 593 593 A successful but zero-length response will be returned. 594 594 595 + ### Adding an attachment 596 + 597 + Send a `POST` request to `$baseURL/ciphers/(cipher UUID)/attachment` 598 + 599 + It is a multipart/form-data post, with the file under the `data`-attribute the single posted entity. 600 + 601 + 602 + POST $baseURL/ciphers/(cipher UUID)/attachment 603 + Content-type: application/json 604 + Authorization: Bearer $access_token 605 + { 606 + "data": { 607 + "filename": "encrypted_filename" 608 + "tempfile": blob 609 + } 610 + } 611 + 612 + The JSON response will then be the complete cipher item, but now containing an entry for the new attachment: 613 + 614 + { 615 + "FolderId"=>nil, 616 + ... 617 + "Data"=> ..., 618 + "Attachments"=> 619 + [ 620 + { "Id"=>"7xytytjp1hc2ijy3n5y5vbbnzcukmo8b", 621 + "Url"=> "https://cdn.bitwarden.com/attachments/(cipher UUID)/7xytytjp1hc2ijy3n5y5vbbnzcukmo8b", 622 + "FileName"=> "2.GOkRA8iZio1KxB+UkJpfcA==|/Mc8ACbPr9CRRQmNKPYHVg==|4BBQf8YTbPupap6qR97qMdn0NJ88GdTgDPIyBsQ46aA=", 623 + "Size"=>"65", 624 + "SizeName"=>"65 Bytes", 625 + "Object"=>"attachment" 626 + } 627 + ], 628 + ..., 629 + "Object"=>"cipher" 630 + } 631 + 632 + ### Deleting an attachment 633 + 634 + Send an empty `DELETE` request to `$baseURL/ciphers/(cipher UUID)/attachment/(attachment id)`: 635 + 636 + DELETE $baseURL/ciphers/(cipher UUID)/attachment/(attachment id) 637 + Authorization: Bearer (access_token) 638 + 639 + A successful but zero-length response will be returned. 640 + 641 + ### Downloading an attachment 642 + 643 + $cdn_url using the official server is https://cdn.bitwarden.com. 644 + 645 + Send an unauthenticated `GET` request to `$cdn_url/attachments/(cipher UUID)/(attachment id)`: 646 + 647 + GET $cdn_url/attachments/(cipher UUID)/(attachment id) 648 + 649 + The file will be sent as a response. 650 + 595 651 ### Folders 596 652 597 653 To create a folder, `POST` to `$baseURL/folders`:
+7 -6
Rakefile
··· 1 - require "rake/testtask" 2 - 3 1 # rake db:create_migration NAME=... 4 2 require "sinatra/activerecord/rake" 5 3 6 - Rake::TestTask.new do |t| 7 - t.pattern = "spec/*_spec.rb" 8 - end 9 - 10 4 namespace :db do 11 5 task :load_config do 12 6 require "./lib/rubywarden.rb" 13 7 end 14 8 end 9 + 10 + require 'rake/testtask' 11 + 12 + Rake::TestTask.new do |t| 13 + t.libs << "spec" 14 + t.pattern = "spec/*_spec.rb" 15 + end
+15
db/migrate/20180818095054_create_attachments.rb
··· 1 + class CreateAttachments < ActiveRecord::Migration[5.1] 2 + def change 3 + remove_column :ciphers, :attachments 4 + create_table :attachments, id: :string, primary_key: :uuid do |t| 5 + t.string :cipher_uuid 6 + t.string :url 7 + t.string :filename 8 + t.integer :size 9 + t.binary :file 10 + t.timestamps 11 + end 12 + add_foreign_key :attachments, :ciphers, { column: :cipher_uuid, primary_key: :uuid } 13 + add_index(:attachments, :cipher_uuid) 14 + end 15 + end
+4
lib/app.rb
··· 18 18 require 'sinatra/namespace' 19 19 20 20 require_relative 'helpers/request_helpers' 21 + require_relative 'helpers/attachment_helpers' 21 22 22 23 require_relative 'routes/api' 23 24 require_relative 'routes/icons' 24 25 require_relative 'routes/identity' 26 + require_relative 'routes/attachments' 25 27 26 28 module Rubywarden 27 29 class App < Sinatra::Base ··· 36 38 end 37 39 38 40 helpers Rubywarden::RequestHelpers 41 + helpers Rubywarden::AttachmentHelpers 39 42 40 43 before do 41 44 if request.content_type.to_s.match(/\Aapplication\/json(;|\z)/) ··· 58 61 register Rubywarden::Routing::Api 59 62 register Rubywarden::Routing::Icons 60 63 register Rubywarden::Routing::Identity 64 + register Rubywarden::Routing::Attachments 61 65 end 62 66 end
+54
lib/attachment.rb
··· 1 + # 2 + # Copyright (c) 2017 joshua stein <jcs@jcs.org> 3 + # 4 + # Permission to use, copy, modify, and distribute this software for any 5 + # purpose with or without fee is hereby granted, provided that the above 6 + # copyright notice and this permission notice appear in all copies. 7 + # 8 + # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 + # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 + # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 11 + # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 + # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 + # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 14 + # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 + # 16 + 17 + class Attachment < DBModel 18 + self.table_name = "attachments" 19 + attr_accessor :context 20 + 21 + before_create :generate_uuid_primary_key 22 + before_create :generate_url 23 + 24 + belongs_to :cipher, foreign_key: :cipher_uuid, inverse_of: :attachments 25 + 26 + def self.build_from_params(params, context) 27 + attachment = new filename: params[:filename], 28 + size: params[:size], 29 + file: params[:file] 30 + attachment.context = context 31 + attachment 32 + end 33 + 34 + def to_hash 35 + { 36 + "Id" => self.uuid, 37 + "Url" => self.url, 38 + "FileName" => self.filename.to_s, 39 + "Size" => self.size, 40 + "SizeName" => human_file_size, 41 + "Object" => "attachment" 42 + } 43 + end 44 + 45 + private 46 + 47 + def generate_url 48 + self.url = context.url("/attachments/#{self.cipher_uuid}/#{self.id}") 49 + end 50 + 51 + def human_file_size 52 + ActiveSupport::NumberHelper.number_to_human_size(self.size) 53 + end 54 + end
+2 -2
lib/cipher.rb
··· 22 22 23 23 belongs_to :user, foreign_key: :user_uuid, inverse_of: :folders 24 24 belongs_to :folder, foreign_key: :folder_uuid, inverse_of: :ciphers, optional: true 25 + has_many :attachments, foreign_key: :cipher_uuid, dependent: :destroy 25 26 26 27 serialize :fields, JSON 27 28 serialize :login, JSON 28 29 serialize :securenote, JSON 29 30 serialize :card, JSON 30 31 serialize :identity, JSON 31 - serialize :attachments, JSON 32 32 33 33 TYPE_LOGIN = 1 34 34 TYPE_NOTE = 2 ··· 89 89 "FolderId" => self.folder_uuid, 90 90 "Favorite" => self.favorite, 91 91 "OrganizationId" => nil, 92 - "Attachments" => self.attachments, 92 + "Attachments" => self.attachments.map(&:to_hash), 93 93 "OrganizationUseTotp" => false, 94 94 "Object" => "cipher", 95 95 "Name" => self.name,
+38
lib/helpers/attachment_helpers.rb
··· 1 + # 2 + # Copyright (c) 2018 joshua stein <jcs@jcs.org> 3 + # 4 + # Permission to use, copy, modify, and distribute this software for any 5 + # purpose with or without fee is hereby granted, provided that the above 6 + # copyright notice and this permission notice appear in all copies. 7 + # 8 + # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 + # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 + # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 11 + # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 + # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 + # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 14 + # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 + # 16 + 17 + module Rubywarden 18 + module AttachmentHelpers 19 + def retrieve_cipher(uuid: ) 20 + d = device_from_bearer 21 + if !d 22 + halt validation_error("invalid bearer") 23 + end 24 + 25 + c = nil 26 + if uuid.blank? || !(c = Cipher.find_by_user_uuid_and_uuid(d.user_uuid, uuid)) 27 + halt validation_error("invalid cipher") 28 + end 29 + return c 30 + end 31 + 32 + def delete_attachment uuid:, attachment_uuid: 33 + cipher = retrieve_cipher uuid: uuid 34 + cipher.attachments.find(attachment_uuid).destroy 35 + "" 36 + end 37 + end 38 + end
+14
lib/helpers/request_helpers.rb
··· 45 45 "Object" => "error", 46 46 }.to_json ] 47 47 end 48 + 49 + def delete_cipher app:, uuid: 50 + d = device_from_bearer 51 + if !d 52 + halt validation_error("invalid bearer") 53 + end 54 + 55 + c = nil 56 + if uuid.blank? || !(c = Cipher.find_by_user_uuid_and_uuid(d.user_uuid, uuid)) 57 + halt validation_error("invalid cipher") 58 + end 59 + c.destroy 60 + "" 61 + end # delete_cipher 48 62 end 49 63 end
+1 -14
lib/routes/api.rb
··· 209 209 210 210 # delete a cipher 211 211 delete "/ciphers/:uuid" do 212 - d = device_from_bearer 213 - if !d 214 - return validation_error("invalid bearer") 215 - end 216 - 217 - c = nil 218 - if params[:uuid].blank? || 219 - !(c = Cipher.find_by_user_uuid_and_uuid(d.user_uuid, params[:uuid])) 220 - return validation_error("invalid cipher") 221 - end 222 - 223 - c.destroy 224 - 225 - "" 212 + delete_cipher app: app, uuid: params[:uuid] 226 213 end 227 214 228 215 #
+69
lib/routes/attachments.rb
··· 1 + # 2 + # Copyright (c) 2018 joshua stein <jcs@jcs.org> 3 + # 4 + # Permission to use, copy, modify, and distribute this software for any 5 + # purpose with or without fee is hereby granted, provided that the above 6 + # copyright notice and this permission notice appear in all copies. 7 + # 8 + # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 + # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 + # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 11 + # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 + # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 + # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 14 + # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 + # 16 + # uses helpers/attachment_helpers 17 + module Rubywarden 18 + module Routing 19 + module Attachments 20 + def self.registered(app) 21 + app.namespace BASE_URL do 22 + post "/ciphers/:uuid/attachment" do 23 + cipher = retrieve_cipher uuid: params[:uuid] 24 + 25 + need_params(:data) do |p| 26 + return validation_error("#{p} cannot be blank") 27 + end 28 + 29 + # we have to extract filename from data -> head, since data -> filename is truncated 30 + filename = nil 31 + if md = params[:data][:head].match(/filename=\"(\S+)\"\r\nContent-Type/) 32 + filename = md[1] 33 + else 34 + return validation_error("filename cannot be blank") 35 + end 36 + 37 + file = params[:data][:tempfile] 38 + attachment_params = { filename: filename, 39 + size: file.size, 40 + file: file.read } 41 + attachment = cipher.attachments.build_from_params(attachment_params, self) 42 + 43 + Attachment.transaction do 44 + if !attachment.save 45 + return validation_error("error saving") 46 + end 47 + 48 + cipher.to_hash.to_json 49 + end 50 + end 51 + 52 + delete "/ciphers/:uuid/attachment/:attachment_id" do 53 + delete_attachment uuid: params[:uuid], attachment_uuid: params[:attachment_id] 54 + end 55 + 56 + post "/ciphers/:uuid/attachment/:attachment_id/delete" do 57 + delete_attachment uuid: params[:uuid], attachment_uuid: params[:attachment_id] 58 + end 59 + end # BASE_URL 60 + 61 + app.get "/attachments/:uuid/:attachment_id" do 62 + a = Attachment.find_by_uuid_and_cipher_uuid(params[:attachment_id], params[:uuid]) 63 + attachment(a.filename) 64 + response.write(a.file) 65 + end 66 + end # registered app 67 + end 68 + end 69 + end
+1
lib/rubywarden.rb
··· 36 36 require "#{APP_ROOT}/lib/device.rb" 37 37 require "#{APP_ROOT}/lib/cipher.rb" 38 38 require "#{APP_ROOT}/lib/folder.rb" 39 + require "#{APP_ROOT}/lib/attachment.rb" 39 40 40 41 BASE_URL ||= "/api" 41 42 IDENTITY_BASE_URL ||= "/identity"
+113
spec/attachment_spec.rb
··· 1 + require "spec_helper.rb" 2 + 3 + @access_token = nil 4 + @cipher_uuid = nil 5 + @cipher = nil 6 + 7 + describe "attachment module" do 8 + before do 9 + User.all.delete_all 10 + 11 + post "/api/accounts/register", { 12 + :name => nil, 13 + :email => "api@example.com", 14 + :masterPasswordHash => Bitwarden.hashPassword("asdf", "api@example.com", 15 + User::DEFAULT_KDF_TYPE, 16 + Bitwarden::KDF::DEFAULT_ITERATIONS[User::DEFAULT_KDF_TYPE]), 17 + :masterPasswordHint => nil, 18 + :key => Bitwarden.makeEncKey( 19 + Bitwarden.makeKey("adsf", "api@example.com", 20 + User::DEFAULT_KDF_TYPE, 21 + Bitwarden::KDF::DEFAULT_ITERATIONS[User::DEFAULT_KDF_TYPE]), 22 + ), 23 + :kdf => Bitwarden::KDF::TYPE_IDS[User::DEFAULT_KDF_TYPE], 24 + :kdfIterations => Bitwarden::KDF::DEFAULT_ITERATIONS[User::DEFAULT_KDF_TYPE], 25 + } 26 + last_response.status.must_equal 200 27 + 28 + post "/identity/connect/token", { 29 + :grant_type => "password", 30 + :username => "api@example.com", 31 + :password => Bitwarden.hashPassword("asdf", "api@example.com", 32 + User::DEFAULT_KDF_TYPE, 33 + Bitwarden::KDF::DEFAULT_ITERATIONS[User::DEFAULT_KDF_TYPE]), 34 + :scope => "api offline_access", 35 + :client_id => "browser", 36 + :deviceType => 3, 37 + :deviceIdentifier => SecureRandom.uuid, 38 + :deviceName => "firefox", 39 + :devicePushToken => "" 40 + } 41 + last_response.status.must_equal 200 42 + 43 + @access_token = last_json_response["access_token"] 44 + 45 + post_json "/api/ciphers", { 46 + :type => 1, 47 + :folderId => nil, 48 + :organizationId => nil, 49 + :name => "2.d7MttWzJTSSKx1qXjHUxlQ==|01Ath5UqFZHk7csk5DVtkQ==|EMLoLREgCUP5Cu4HqIhcLqhiZHn+NsUDp8dAg1Xu0Io=", 50 + :notes => nil, 51 + :favorite => false, 52 + :login => { 53 + :uri => "2.T57BwAuV8ubIn/sZPbQC+A==|EhUSSpJWSzSYOdJ/AQzfXuUXxwzcs/6C4tOXqhWAqcM=|OWV2VIqLfoWPs9DiouXGUOtTEkVeklbtJQHkQFIXkC8=", 54 + :username => "2.JbFkAEZPnuMm70cdP44wtA==|fsN6nbT+udGmOWv8K4otgw==|JbtwmNQa7/48KszT2hAdxpmJ6DRPZst0EDEZx5GzesI=", 55 + :password => "2.e83hIsk6IRevSr/H1lvZhg==|48KNkSCoTacopXRmIZsbWg==|CIcWgNbaIN2ix2Fx1Gar6rWQeVeboehp4bioAwngr0o=", 56 + :totp => nil 57 + } 58 + }, { 59 + "HTTP_AUTHORIZATION" => "Bearer #{@access_token}", 60 + } 61 + @cipher_uuid = last_json_response["Id"] 62 + @cipher = Cipher.find_by_uuid(@cipher_uuid) 63 + end 64 + 65 + 66 + it "does not allow access with bogus bearer token" do 67 + post_json "/api/ciphers/#{@cipher_uuid}/attachment", { 68 + data: "" 69 + }, { 70 + "HTTP_AUTHORIZATION" => "Bearer #{@access_token.upcase}", 71 + } 72 + 73 + last_response.status.wont_equal 200 74 + end 75 + 76 + it "allows creating, downloading and deleting an attachment" do 77 + post "/api/ciphers/#{@cipher_uuid}/attachment", { 78 + data: Rack::Test::UploadedFile.new(StringIO.new("dummy"), original_filename: "test") 79 + }, { 80 + "HTTP_AUTHORIZATION" => "Bearer #{@access_token}" 81 + } 82 + last_response.status.must_equal 200 83 + attachment = last_json_response["Attachments"].first 84 + 85 + # downloading 86 + get attachment["Url"] 87 + last_response.status.must_equal 200 88 + 89 + # deleting 90 + delete_json "/api/ciphers/#{@cipher_uuid}/attachment/#{attachment["Id"]}", {}, { 91 + "HTTP_AUTHORIZATION" => "Bearer #{@access_token}", 92 + } 93 + 94 + last_response.status.must_equal 200 95 + Cipher.find_by_uuid(@cipher_uuid).attachments.must_be_empty 96 + Dir.glob("tmp/spec/data/attachments/#{@cipher_uuid}/*").must_be_empty 97 + end 98 + 99 + it "deletes attachments when cipher is deleted" do 100 + post "/api/ciphers/#{@cipher_uuid}/attachment", { 101 + data: Rack::Test::UploadedFile.new(StringIO.new("dummy"), original_filename: "test") 102 + }, { 103 + "HTTP_AUTHORIZATION" => "Bearer #{@access_token}" 104 + } 105 + last_response.status.must_equal 200 106 + delete_json "/api/ciphers/#{@cipher_uuid}", {}, { 107 + "HTTP_AUTHORIZATION" => "Bearer #{@access_token}", 108 + } 109 + 110 + Cipher.find_by_uuid(@cipher_uuid).must_be_nil 111 + Attachment.where(cipher_uuid: @cipher_uuid).must_be_empty 112 + end 113 + end
-5
spec/spec_helper.rb
··· 3 3 require "minitest/autorun" 4 4 require "rack/test" 5 5 6 - # clear out test db 7 - if File.exist?(f = File.dirname(__FILE__) + "/../db/test.sqlite3") 8 - File.unlink(f) 9 - end 10 - 11 6 # most tests require this to be on 12 7 ALLOW_SIGNUPS = true 13 8