├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .tool-versions ├── .travis.yml ├── Appraisals ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── MIT-LICENSE ├── README.md ├── Rakefile ├── active_storage-postgresql.gemspec ├── app └── controllers │ └── active_storage │ └── postgresql_controller.rb ├── bin └── test ├── config └── routes.rb ├── db └── migrate │ └── 20180530020601_create_active_storage_postgresql_tables.rb ├── gemfiles ├── rails_6.0.gemfile ├── rails_6.0.gemfile.lock ├── rails_6.1.gemfile ├── rails_6.1.gemfile.lock ├── rails_7.0.gemfile └── rails_7.0.gemfile.lock ├── lib ├── active_storage │ ├── postgresql.rb │ ├── postgresql │ │ ├── engine.rb │ │ ├── file.rb │ │ └── version.rb │ └── service │ │ ├── postgre_sql_service.rb │ │ └── postgresql_service.rb └── tasks │ └── active_storage │ └── postgresql_tasks.rake └── test ├── active_storage ├── postgresql_test.rb ├── public_postgresql_test.rb └── shared_service_tests.rb ├── controllers └── postgresql_controller_test.rb ├── dummy ├── .ruby-version ├── Rakefile ├── app │ ├── assets │ │ ├── config │ │ │ └── manifest.js │ │ ├── images │ │ │ └── .keep │ │ ├── javascripts │ │ │ ├── application.js │ │ │ ├── cable.js │ │ │ └── channels │ │ │ │ └── .keep │ │ └── stylesheets │ │ │ └── application.css │ ├── channels │ │ └── application_cable │ │ │ ├── channel.rb │ │ │ └── connection.rb │ ├── controllers │ │ ├── application_controller.rb │ │ └── concerns │ │ │ └── .keep │ ├── helpers │ │ └── application_helper.rb │ ├── jobs │ │ └── application_job.rb │ ├── mailers │ │ └── application_mailer.rb │ ├── models │ │ ├── application_record.rb │ │ └── concerns │ │ │ └── .keep │ └── views │ │ └── layouts │ │ ├── application.html.erb │ │ ├── mailer.html.erb │ │ └── mailer.text.erb ├── bin │ ├── bundle │ ├── rails │ ├── rake │ ├── setup │ ├── update │ └── yarn ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── cable.yml │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ ├── production.rb │ │ └── test.rb │ ├── initializers │ │ ├── application_controller_renderer.rb │ │ ├── backtrace_silencers.rb │ │ ├── content_security_policy.rb │ │ ├── cookies_serializer.rb │ │ ├── filter_parameter_logging.rb │ │ ├── inflections.rb │ │ ├── mime_types.rb │ │ └── wrap_parameters.rb │ ├── locales │ │ └── en.yml │ ├── puma.rb │ ├── routes.rb │ ├── spring.rb │ └── storage.yml ├── db │ └── schema.rb ├── lib │ └── assets │ │ └── .keep ├── log │ └── .keep ├── package.json ├── public │ ├── 404.html │ ├── 422.html │ ├── 500.html │ ├── apple-touch-icon-precomposed.png │ ├── apple-touch-icon.png │ └── favicon.ico └── tmp │ └── .keep ├── fixtures └── files │ └── racecar.jpg └── test_helper.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | pull_request: 5 | 6 | jobs: 7 | ci: 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | os: [ ubuntu, macos, windows ] 12 | ruby: [ '2.5', ruby ] 13 | runs-on: ${{ matrix.os }}-latest 14 | env: 15 | PGSERVICE: postgres 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: ${{ matrix.ruby }} 22 | - uses: ikalnytskyi/action-setup-postgres@v4 23 | 24 | - run: bundle install 25 | - run: createdb activestorage_pglo_test 26 | 27 | # For some reason the db migrations needs to be executed explicitly on Windows 28 | - name: Copy and run migrations on Windows 29 | if: matrix.os == 'windows' 30 | run: | 31 | ridk exec cp -rv db/migrate test/dummy/db/ 32 | rake -f test/dummy/Rakefile active_storage:install db:migrate RAILS_ENV=test 33 | - run: bundle exec rake test 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | log/*.log 3 | pkg/ 4 | Gemfile.lock 5 | test/dummy/db/*.sqlite3 6 | test/dummy/db/*.sqlite3-journal 7 | test/dummy/log/*.log 8 | test/dummy/node_modules/ 9 | test/dummy/yarn-error.log 10 | test/dummy/storage/ 11 | test/dummy/tmp/ 12 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.0.1 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | sudo: false 3 | 4 | before_script: 5 | - createdb activestorage_pglo_test 6 | 7 | services: 8 | - postgresql 9 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "rails-6.0" do 2 | gem "rails", "~> 6.0.0" 3 | gem 'net-smtp', require: false 4 | end 5 | 6 | appraise "rails-6.1" do 7 | gem "rails", "~> 6.1.0" 8 | gem 'net-smtp', require: false 9 | end 10 | 11 | appraise "rails-7.0" do 12 | gem "rails", "~> 7.0.0" 13 | end 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # ActiveStorage::PostgreSQL Changes 2 | 3 | 0.3.1 4 | --------- 5 | 6 | - Allow use if pg 1.4.6 7 | 8 | 0.3.0 9 | --------- 10 | 11 | - Add support for public access 12 | - Add support for Ruby 3.0 13 | - Add support for Rails 6.1 14 | - Remove support for Rails 5.2 15 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at lachlan.sylvester@publicisfrontfoot.com.au. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | # Declare your gem's dependencies in activestorage-postgresql.gemspec. 5 | # Bundler will treat runtime dependencies like base dependencies, and 6 | # development dependencies will be added by default to the :development group. 7 | gemspec 8 | 9 | gem 'image_processing', '~> 1.2' 10 | # Declare any dependencies that are still in development here instead of in 11 | # your gemspec. These might include edge Rails or gems from your path or 12 | # Git. Remember to move these dependencies to your gemspec before releasing 13 | # your gem to rubygems.org. 14 | 15 | # To use a debugger 16 | # gem 'byebug', group: [:development, :test] 17 | 18 | # Required on Windows only 19 | gem 'tzinfo-data' 20 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Lachlan Sylvester 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ActiveStorage::PostgreSQL 2 | 3 | [](https://badge.fury.io/rb/active_storage-postgresql) 4 | [](https://travis-ci.com/lsylvester/active_storage-postgresql) 5 | 6 | ActiveStorage Service to store files PostgeSQL. 7 | 8 | Files are stored in PostgreSQL as Large Objects, which provide streaming style access. 9 | More information about Large Objects can be found [here](https://www.postgresql.org/docs/current/static/largeobjects.html). 10 | 11 | This allows use of ActiveStorage on hosting platforms with ephemeral file systems such as Heroku without relying on third party storage services. 12 | 13 | There are [some limits](https://dba.stackexchange.com/questions/127270/what-are-the-limits-of-postgresqls-large-object-facility) to the storage of Large Objects in PostgerSQL, so this is only recommended for prototyping and very small sites. 14 | 15 | ## Installation 16 | Add this line to your application's Gemfile: 17 | 18 | ```ruby 19 | gem 'active_storage-postgresql' 20 | ``` 21 | 22 | And then execute: 23 | ```bash 24 | $ bundle 25 | ``` 26 | 27 | In config/storage.yml set PostgreSQL as the service 28 | 29 | ```yaml 30 | local: 31 | service: PostgreSQL 32 | ``` 33 | 34 | Copy over the migrations: 35 | 36 | ``` 37 | rails active_storage:install 38 | rails active_storage:postgresql:install 39 | ``` 40 | 41 | ## Contributing 42 | 43 | Bug reports and pull requests are welcome on GitHub at https://github.com/lsylvester/active_storage-postgresql. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 44 | 45 | ## Acknowledgments 46 | 47 | Special thanks to [diogob](https://github.com/diogob) whos work on [carrierwave-postgresql](https://github.com/diogob/carrierwave-postgresql) inspired this. 48 | 49 | ## License 50 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 51 | 52 | ## Code of Conduct 53 | 54 | Everyone interacting in the ActiveStorage::PostgreSQL project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/lsylvester/active_storage-postgresql/blob/main/CODE_OF_CONDUCT.md). 55 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | begin 2 | require 'bundler/setup' 3 | rescue LoadError 4 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks' 5 | end 6 | 7 | require 'rdoc/task' 8 | 9 | RDoc::Task.new(:rdoc) do |rdoc| 10 | rdoc.rdoc_dir = 'rdoc' 11 | rdoc.title = 'Activestorage::PostgreSQL' 12 | rdoc.options << '--line-numbers' 13 | rdoc.rdoc_files.include('README.md') 14 | rdoc.rdoc_files.include('lib/**/*.rb') 15 | end 16 | 17 | require 'bundler/gem_tasks' 18 | 19 | require 'rake/testtask' 20 | 21 | Rake::TestTask.new(:test) do |t| 22 | t.libs << 'test' 23 | t.pattern = 'test/**/*_test.rb' 24 | t.verbose = false 25 | end 26 | 27 | task default: :test 28 | -------------------------------------------------------------------------------- /active_storage-postgresql.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path("lib", __dir__) 2 | 3 | # Maintain your gem's version: 4 | require "active_storage/postgresql/version" 5 | 6 | # Describe your gem and declare its dependencies: 7 | Gem::Specification.new do |s| 8 | s.name = "active_storage-postgresql" 9 | s.version = ActiveStorage::PostgreSQL::VERSION 10 | s.authors = ["Lachlan Sylvester"] 11 | s.email = ["lachlan.sylvester@hypothetical.com.au"] 12 | s.homepage = "https://github.com/lsylvester/active_storage-postgresql" 13 | s.summary = "PostgreSQL Adapter for Active Storage" 14 | s.license = "MIT" 15 | 16 | s.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] 17 | 18 | s.add_dependency "rails", ">= 6.0" 19 | s.add_dependency "pg", ">= 1.0", "< 2.0", 20 | # Avoid incompatible pg versions, see: https://github.com/ged/ruby-pg/pull/498 21 | "!= 1.3.0", "!= 1.3.1", "!= 1.3.2", "!= 1.3.3", "!= 1.3.4", "!= 1.3.5", 22 | "!= 1.4.0", "!= 1.4.1", "!= 1.4.2", "!= 1.4.3", "!= 1.4.4", "!= 1.4.5" 23 | 24 | s.add_development_dependency "pry", "~> 0.11" 25 | s.add_development_dependency "database_cleaner", "~> 1.7" 26 | s.add_development_dependency "appraisal" 27 | end 28 | -------------------------------------------------------------------------------- /app/controllers/active_storage/postgresql_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Serves files stored with the disk service in the same way that the cloud services do. 4 | # This means using expiring, signed URLs that are meant for immediate access, not permanent linking. 5 | # Always go through the BlobsController, or your own authenticated controller, rather than directly 6 | # to the service url. 7 | 8 | class ActiveStorage::PostgresqlController < ActiveStorage::BaseController 9 | 10 | skip_forgery_protection 11 | 12 | def show 13 | if key = decode_verified_key 14 | response.headers["Content-Type"] = key[:content_type] || DEFAULT_SEND_FILE_TYPE 15 | response.headers["Content-Disposition"] = key[:disposition] || DEFAULT_SEND_FILE_DISPOSITION 16 | size = ActiveStorage::PostgreSQL::File.open(key[:key], &:size) 17 | 18 | ranges = Rack::Utils.get_byte_ranges(request.get_header('HTTP_RANGE'), size) 19 | 20 | if ranges.nil? || ranges.length > 1 21 | # # No ranges, or multiple ranges (which we don't support): 22 | # # TODO: Support multiple byte-ranges 23 | self.status = :ok 24 | range = 0..size-1 25 | 26 | elsif ranges.empty? 27 | head 416, content_range: "bytes */#{size}" 28 | return 29 | else 30 | range = ranges[0] 31 | self.status = :partial_content 32 | response.headers["Content-Range"] = "bytes #{range.begin}-#{range.end}/#{size}" 33 | end 34 | self.response_body = postgresql_service.download_chunk(key[:key], range) 35 | else 36 | head :not_found 37 | end 38 | rescue ActiveRecord::RecordNotFound 39 | head :not_found 40 | end 41 | 42 | def update 43 | if token = decode_verified_token 44 | if acceptable_content?(token) 45 | postgresql_service.upload token[:key], request.body, checksum: token[:checksum] 46 | head :no_content 47 | else 48 | head :unprocessable_entity 49 | end 50 | else 51 | head :not_found 52 | end 53 | rescue ActiveStorage::IntegrityError 54 | head :unprocessable_entity 55 | end 56 | 57 | private 58 | def postgresql_service 59 | ActiveStorage::Blob.service 60 | end 61 | 62 | 63 | def decode_verified_key 64 | ActiveStorage.verifier.verified(params[:encoded_key], purpose: :blob_key) 65 | end 66 | 67 | 68 | def decode_verified_token 69 | ActiveStorage.verifier.verified(params[:encoded_token], purpose: :blob_token) 70 | end 71 | 72 | def acceptable_content?(token) 73 | token[:content_type] == request.content_mime_type && token[:content_length] == request.content_length 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $: << File.expand_path("../test", __dir__) 3 | 4 | require "bundler/setup" 5 | require "rails/plugin/test" 6 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | get "/rails/active_storage/postgresql/:encoded_key/*filename" => "active_storage/postgresql#show", as: :rails_postgresql_service 5 | put "/rails/active_storage/postgresql/:encoded_token" => "active_storage/postgresql#update", as: :update_rails_postgresql_service 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20180530020601_create_active_storage_postgresql_tables.rb: -------------------------------------------------------------------------------- 1 | class CreateActiveStoragePostgresqlTables < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table :active_storage_postgresql_files do |t| 4 | t.oid :oid 5 | t.string :key 6 | 7 | t.index :key, unique: true 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/rails_6.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "image_processing", "~> 1.2" 6 | gem "rails", "~> 6.0.0" 7 | gem "net-smtp", require: false 8 | 9 | gemspec path: "../" 10 | -------------------------------------------------------------------------------- /gemfiles/rails_6.0.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | active_storage-postgresql (0.3.0) 5 | pg (>= 1.0, < 1.3) 6 | rails (>= 6.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | actioncable (6.0.5) 12 | actionpack (= 6.0.5) 13 | nio4r (~> 2.0) 14 | websocket-driver (>= 0.6.1) 15 | actionmailbox (6.0.5) 16 | actionpack (= 6.0.5) 17 | activejob (= 6.0.5) 18 | activerecord (= 6.0.5) 19 | activestorage (= 6.0.5) 20 | activesupport (= 6.0.5) 21 | mail (>= 2.7.1) 22 | actionmailer (6.0.5) 23 | actionpack (= 6.0.5) 24 | actionview (= 6.0.5) 25 | activejob (= 6.0.5) 26 | mail (~> 2.5, >= 2.5.4) 27 | rails-dom-testing (~> 2.0) 28 | actionpack (6.0.5) 29 | actionview (= 6.0.5) 30 | activesupport (= 6.0.5) 31 | rack (~> 2.0, >= 2.0.8) 32 | rack-test (>= 0.6.3) 33 | rails-dom-testing (~> 2.0) 34 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 35 | actiontext (6.0.5) 36 | actionpack (= 6.0.5) 37 | activerecord (= 6.0.5) 38 | activestorage (= 6.0.5) 39 | activesupport (= 6.0.5) 40 | nokogiri (>= 1.8.5) 41 | actionview (6.0.5) 42 | activesupport (= 6.0.5) 43 | builder (~> 3.1) 44 | erubi (~> 1.4) 45 | rails-dom-testing (~> 2.0) 46 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 47 | activejob (6.0.5) 48 | activesupport (= 6.0.5) 49 | globalid (>= 0.3.6) 50 | activemodel (6.0.5) 51 | activesupport (= 6.0.5) 52 | activerecord (6.0.5) 53 | activemodel (= 6.0.5) 54 | activesupport (= 6.0.5) 55 | activestorage (6.0.5) 56 | actionpack (= 6.0.5) 57 | activejob (= 6.0.5) 58 | activerecord (= 6.0.5) 59 | marcel (~> 1.0) 60 | activesupport (6.0.5) 61 | concurrent-ruby (~> 1.0, >= 1.0.2) 62 | i18n (>= 0.7, < 2) 63 | minitest (~> 5.1) 64 | tzinfo (~> 1.1) 65 | zeitwerk (~> 2.2, >= 2.2.2) 66 | appraisal (2.4.1) 67 | bundler 68 | rake 69 | thor (>= 0.14.0) 70 | builder (3.2.4) 71 | coderay (1.1.3) 72 | concurrent-ruby (1.1.10) 73 | crass (1.0.6) 74 | database_cleaner (1.99.0) 75 | digest (3.1.0) 76 | erubi (1.10.0) 77 | ffi (1.15.5) 78 | globalid (1.0.0) 79 | activesupport (>= 5.0) 80 | i18n (1.10.0) 81 | concurrent-ruby (~> 1.0) 82 | image_processing (1.12.2) 83 | mini_magick (>= 4.9.5, < 5) 84 | ruby-vips (>= 2.0.17, < 3) 85 | loofah (2.18.0) 86 | crass (~> 1.0.2) 87 | nokogiri (>= 1.5.9) 88 | mail (2.7.1) 89 | mini_mime (>= 0.1.1) 90 | marcel (1.0.2) 91 | method_source (1.0.0) 92 | mini_magick (4.11.0) 93 | mini_mime (1.1.2) 94 | minitest (5.15.0) 95 | net-protocol (0.1.3) 96 | timeout 97 | net-smtp (0.3.1) 98 | digest 99 | net-protocol 100 | timeout 101 | nio4r (2.5.8) 102 | nokogiri (1.13.6-arm64-darwin) 103 | racc (~> 1.4) 104 | pg (1.2.3) 105 | pry (0.14.1) 106 | coderay (~> 1.1) 107 | method_source (~> 1.0) 108 | racc (1.6.0) 109 | rack (2.2.3) 110 | rack-test (1.1.0) 111 | rack (>= 1.0, < 3) 112 | rails (6.0.5) 113 | actioncable (= 6.0.5) 114 | actionmailbox (= 6.0.5) 115 | actionmailer (= 6.0.5) 116 | actionpack (= 6.0.5) 117 | actiontext (= 6.0.5) 118 | actionview (= 6.0.5) 119 | activejob (= 6.0.5) 120 | activemodel (= 6.0.5) 121 | activerecord (= 6.0.5) 122 | activestorage (= 6.0.5) 123 | activesupport (= 6.0.5) 124 | bundler (>= 1.3.0) 125 | railties (= 6.0.5) 126 | sprockets-rails (>= 2.0.0) 127 | rails-dom-testing (2.0.3) 128 | activesupport (>= 4.2.0) 129 | nokogiri (>= 1.6) 130 | rails-html-sanitizer (1.4.2) 131 | loofah (~> 2.3) 132 | railties (6.0.5) 133 | actionpack (= 6.0.5) 134 | activesupport (= 6.0.5) 135 | method_source 136 | rake (>= 0.8.7) 137 | thor (>= 0.20.3, < 2.0) 138 | rake (13.0.6) 139 | ruby-vips (2.1.4) 140 | ffi (~> 1.12) 141 | sprockets (4.0.3) 142 | concurrent-ruby (~> 1.0) 143 | rack (> 1, < 3) 144 | sprockets-rails (3.4.2) 145 | actionpack (>= 5.2) 146 | activesupport (>= 5.2) 147 | sprockets (>= 3.0.0) 148 | thor (1.2.1) 149 | thread_safe (0.3.6) 150 | timeout (0.3.0) 151 | tzinfo (1.2.9) 152 | thread_safe (~> 0.1) 153 | websocket-driver (0.7.5) 154 | websocket-extensions (>= 0.1.0) 155 | websocket-extensions (0.1.5) 156 | zeitwerk (2.5.4) 157 | 158 | PLATFORMS 159 | arm64-darwin-21 160 | 161 | DEPENDENCIES 162 | active_storage-postgresql! 163 | appraisal 164 | database_cleaner (~> 1.7) 165 | image_processing (~> 1.2) 166 | net-smtp 167 | pry (~> 0.11) 168 | rails (~> 6.0.0) 169 | 170 | BUNDLED WITH 171 | 2.3.14 172 | -------------------------------------------------------------------------------- /gemfiles/rails_6.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "image_processing", "~> 1.2" 6 | gem "rails", "~> 6.1.0" 7 | gem "net-smtp", require: false 8 | 9 | gemspec path: "../" 10 | -------------------------------------------------------------------------------- /gemfiles/rails_6.1.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | active_storage-postgresql (0.3.0) 5 | pg (>= 1.0, < 1.3) 6 | rails (>= 6.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | actioncable (6.1.6) 12 | actionpack (= 6.1.6) 13 | activesupport (= 6.1.6) 14 | nio4r (~> 2.0) 15 | websocket-driver (>= 0.6.1) 16 | actionmailbox (6.1.6) 17 | actionpack (= 6.1.6) 18 | activejob (= 6.1.6) 19 | activerecord (= 6.1.6) 20 | activestorage (= 6.1.6) 21 | activesupport (= 6.1.6) 22 | mail (>= 2.7.1) 23 | actionmailer (6.1.6) 24 | actionpack (= 6.1.6) 25 | actionview (= 6.1.6) 26 | activejob (= 6.1.6) 27 | activesupport (= 6.1.6) 28 | mail (~> 2.5, >= 2.5.4) 29 | rails-dom-testing (~> 2.0) 30 | actionpack (6.1.6) 31 | actionview (= 6.1.6) 32 | activesupport (= 6.1.6) 33 | rack (~> 2.0, >= 2.0.9) 34 | rack-test (>= 0.6.3) 35 | rails-dom-testing (~> 2.0) 36 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 37 | actiontext (6.1.6) 38 | actionpack (= 6.1.6) 39 | activerecord (= 6.1.6) 40 | activestorage (= 6.1.6) 41 | activesupport (= 6.1.6) 42 | nokogiri (>= 1.8.5) 43 | actionview (6.1.6) 44 | activesupport (= 6.1.6) 45 | builder (~> 3.1) 46 | erubi (~> 1.4) 47 | rails-dom-testing (~> 2.0) 48 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 49 | activejob (6.1.6) 50 | activesupport (= 6.1.6) 51 | globalid (>= 0.3.6) 52 | activemodel (6.1.6) 53 | activesupport (= 6.1.6) 54 | activerecord (6.1.6) 55 | activemodel (= 6.1.6) 56 | activesupport (= 6.1.6) 57 | activestorage (6.1.6) 58 | actionpack (= 6.1.6) 59 | activejob (= 6.1.6) 60 | activerecord (= 6.1.6) 61 | activesupport (= 6.1.6) 62 | marcel (~> 1.0) 63 | mini_mime (>= 1.1.0) 64 | activesupport (6.1.6) 65 | concurrent-ruby (~> 1.0, >= 1.0.2) 66 | i18n (>= 1.6, < 2) 67 | minitest (>= 5.1) 68 | tzinfo (~> 2.0) 69 | zeitwerk (~> 2.3) 70 | appraisal (2.4.1) 71 | bundler 72 | rake 73 | thor (>= 0.14.0) 74 | builder (3.2.4) 75 | coderay (1.1.3) 76 | concurrent-ruby (1.1.10) 77 | crass (1.0.6) 78 | database_cleaner (1.99.0) 79 | digest (3.1.0) 80 | erubi (1.10.0) 81 | ffi (1.15.5) 82 | globalid (1.0.0) 83 | activesupport (>= 5.0) 84 | i18n (1.10.0) 85 | concurrent-ruby (~> 1.0) 86 | image_processing (1.12.2) 87 | mini_magick (>= 4.9.5, < 5) 88 | ruby-vips (>= 2.0.17, < 3) 89 | loofah (2.18.0) 90 | crass (~> 1.0.2) 91 | nokogiri (>= 1.5.9) 92 | mail (2.7.1) 93 | mini_mime (>= 0.1.1) 94 | marcel (1.0.2) 95 | method_source (1.0.0) 96 | mini_magick (4.11.0) 97 | mini_mime (1.1.2) 98 | minitest (5.15.0) 99 | net-protocol (0.1.3) 100 | timeout 101 | net-smtp (0.3.1) 102 | digest 103 | net-protocol 104 | timeout 105 | nio4r (2.5.8) 106 | nokogiri (1.13.6-arm64-darwin) 107 | racc (~> 1.4) 108 | pg (1.2.3) 109 | pry (0.14.1) 110 | coderay (~> 1.1) 111 | method_source (~> 1.0) 112 | racc (1.6.0) 113 | rack (2.2.3) 114 | rack-test (1.1.0) 115 | rack (>= 1.0, < 3) 116 | rails (6.1.6) 117 | actioncable (= 6.1.6) 118 | actionmailbox (= 6.1.6) 119 | actionmailer (= 6.1.6) 120 | actionpack (= 6.1.6) 121 | actiontext (= 6.1.6) 122 | actionview (= 6.1.6) 123 | activejob (= 6.1.6) 124 | activemodel (= 6.1.6) 125 | activerecord (= 6.1.6) 126 | activestorage (= 6.1.6) 127 | activesupport (= 6.1.6) 128 | bundler (>= 1.15.0) 129 | railties (= 6.1.6) 130 | sprockets-rails (>= 2.0.0) 131 | rails-dom-testing (2.0.3) 132 | activesupport (>= 4.2.0) 133 | nokogiri (>= 1.6) 134 | rails-html-sanitizer (1.4.2) 135 | loofah (~> 2.3) 136 | railties (6.1.6) 137 | actionpack (= 6.1.6) 138 | activesupport (= 6.1.6) 139 | method_source 140 | rake (>= 12.2) 141 | thor (~> 1.0) 142 | rake (13.0.6) 143 | ruby-vips (2.1.4) 144 | ffi (~> 1.12) 145 | sprockets (4.0.3) 146 | concurrent-ruby (~> 1.0) 147 | rack (> 1, < 3) 148 | sprockets-rails (3.4.2) 149 | actionpack (>= 5.2) 150 | activesupport (>= 5.2) 151 | sprockets (>= 3.0.0) 152 | thor (1.2.1) 153 | timeout (0.3.0) 154 | tzinfo (2.0.4) 155 | concurrent-ruby (~> 1.0) 156 | websocket-driver (0.7.5) 157 | websocket-extensions (>= 0.1.0) 158 | websocket-extensions (0.1.5) 159 | zeitwerk (2.5.4) 160 | 161 | PLATFORMS 162 | arm64-darwin-21 163 | 164 | DEPENDENCIES 165 | active_storage-postgresql! 166 | appraisal 167 | database_cleaner (~> 1.7) 168 | image_processing (~> 1.2) 169 | net-smtp 170 | pry (~> 0.11) 171 | rails (~> 6.1.0) 172 | 173 | BUNDLED WITH 174 | 2.3.14 175 | -------------------------------------------------------------------------------- /gemfiles/rails_7.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "image_processing", "~> 1.2" 6 | gem "rails", "~> 7.0.0" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_7.0.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | active_storage-postgresql (0.3.0) 5 | pg (>= 1.0, < 1.3) 6 | rails (>= 6.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | actioncable (7.0.3) 12 | actionpack (= 7.0.3) 13 | activesupport (= 7.0.3) 14 | nio4r (~> 2.0) 15 | websocket-driver (>= 0.6.1) 16 | actionmailbox (7.0.3) 17 | actionpack (= 7.0.3) 18 | activejob (= 7.0.3) 19 | activerecord (= 7.0.3) 20 | activestorage (= 7.0.3) 21 | activesupport (= 7.0.3) 22 | mail (>= 2.7.1) 23 | net-imap 24 | net-pop 25 | net-smtp 26 | actionmailer (7.0.3) 27 | actionpack (= 7.0.3) 28 | actionview (= 7.0.3) 29 | activejob (= 7.0.3) 30 | activesupport (= 7.0.3) 31 | mail (~> 2.5, >= 2.5.4) 32 | net-imap 33 | net-pop 34 | net-smtp 35 | rails-dom-testing (~> 2.0) 36 | actionpack (7.0.3) 37 | actionview (= 7.0.3) 38 | activesupport (= 7.0.3) 39 | rack (~> 2.0, >= 2.2.0) 40 | rack-test (>= 0.6.3) 41 | rails-dom-testing (~> 2.0) 42 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 43 | actiontext (7.0.3) 44 | actionpack (= 7.0.3) 45 | activerecord (= 7.0.3) 46 | activestorage (= 7.0.3) 47 | activesupport (= 7.0.3) 48 | globalid (>= 0.6.0) 49 | nokogiri (>= 1.8.5) 50 | actionview (7.0.3) 51 | activesupport (= 7.0.3) 52 | builder (~> 3.1) 53 | erubi (~> 1.4) 54 | rails-dom-testing (~> 2.0) 55 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 56 | activejob (7.0.3) 57 | activesupport (= 7.0.3) 58 | globalid (>= 0.3.6) 59 | activemodel (7.0.3) 60 | activesupport (= 7.0.3) 61 | activerecord (7.0.3) 62 | activemodel (= 7.0.3) 63 | activesupport (= 7.0.3) 64 | activestorage (7.0.3) 65 | actionpack (= 7.0.3) 66 | activejob (= 7.0.3) 67 | activerecord (= 7.0.3) 68 | activesupport (= 7.0.3) 69 | marcel (~> 1.0) 70 | mini_mime (>= 1.1.0) 71 | activesupport (7.0.3) 72 | concurrent-ruby (~> 1.0, >= 1.0.2) 73 | i18n (>= 1.6, < 2) 74 | minitest (>= 5.1) 75 | tzinfo (~> 2.0) 76 | appraisal (2.4.1) 77 | bundler 78 | rake 79 | thor (>= 0.14.0) 80 | builder (3.2.4) 81 | coderay (1.1.3) 82 | concurrent-ruby (1.1.10) 83 | crass (1.0.6) 84 | database_cleaner (1.99.0) 85 | digest (3.1.0) 86 | erubi (1.10.0) 87 | ffi (1.15.5) 88 | globalid (1.0.0) 89 | activesupport (>= 5.0) 90 | i18n (1.10.0) 91 | concurrent-ruby (~> 1.0) 92 | image_processing (1.12.2) 93 | mini_magick (>= 4.9.5, < 5) 94 | ruby-vips (>= 2.0.17, < 3) 95 | loofah (2.18.0) 96 | crass (~> 1.0.2) 97 | nokogiri (>= 1.5.9) 98 | mail (2.7.1) 99 | mini_mime (>= 0.1.1) 100 | marcel (1.0.2) 101 | method_source (1.0.0) 102 | mini_magick (4.11.0) 103 | mini_mime (1.1.2) 104 | minitest (5.15.0) 105 | net-imap (0.2.3) 106 | digest 107 | net-protocol 108 | strscan 109 | net-pop (0.1.1) 110 | digest 111 | net-protocol 112 | timeout 113 | net-protocol (0.1.3) 114 | timeout 115 | net-smtp (0.3.1) 116 | digest 117 | net-protocol 118 | timeout 119 | nio4r (2.5.8) 120 | nokogiri (1.13.6-arm64-darwin) 121 | racc (~> 1.4) 122 | pg (1.2.3) 123 | pry (0.14.1) 124 | coderay (~> 1.1) 125 | method_source (~> 1.0) 126 | racc (1.6.0) 127 | rack (2.2.3) 128 | rack-test (1.1.0) 129 | rack (>= 1.0, < 3) 130 | rails (7.0.3) 131 | actioncable (= 7.0.3) 132 | actionmailbox (= 7.0.3) 133 | actionmailer (= 7.0.3) 134 | actionpack (= 7.0.3) 135 | actiontext (= 7.0.3) 136 | actionview (= 7.0.3) 137 | activejob (= 7.0.3) 138 | activemodel (= 7.0.3) 139 | activerecord (= 7.0.3) 140 | activestorage (= 7.0.3) 141 | activesupport (= 7.0.3) 142 | bundler (>= 1.15.0) 143 | railties (= 7.0.3) 144 | rails-dom-testing (2.0.3) 145 | activesupport (>= 4.2.0) 146 | nokogiri (>= 1.6) 147 | rails-html-sanitizer (1.4.2) 148 | loofah (~> 2.3) 149 | railties (7.0.3) 150 | actionpack (= 7.0.3) 151 | activesupport (= 7.0.3) 152 | method_source 153 | rake (>= 12.2) 154 | thor (~> 1.0) 155 | zeitwerk (~> 2.5) 156 | rake (13.0.6) 157 | ruby-vips (2.1.4) 158 | ffi (~> 1.12) 159 | strscan (3.0.3) 160 | thor (1.2.1) 161 | timeout (0.3.0) 162 | tzinfo (2.0.4) 163 | concurrent-ruby (~> 1.0) 164 | websocket-driver (0.7.5) 165 | websocket-extensions (>= 0.1.0) 166 | websocket-extensions (0.1.5) 167 | zeitwerk (2.5.4) 168 | 169 | PLATFORMS 170 | arm64-darwin-21 171 | 172 | DEPENDENCIES 173 | active_storage-postgresql! 174 | appraisal 175 | database_cleaner (~> 1.7) 176 | image_processing (~> 1.2) 177 | pry (~> 0.11) 178 | rails (~> 7.0.0) 179 | 180 | BUNDLED WITH 181 | 2.3.14 182 | -------------------------------------------------------------------------------- /lib/active_storage/postgresql.rb: -------------------------------------------------------------------------------- 1 | require "active_storage/postgresql/engine" 2 | 3 | module ActiveStorage 4 | module PostgreSQL 5 | extend ActiveSupport::Autoload 6 | 7 | autoload :File, "active_storage/postgresql/file" 8 | 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/active_storage/postgresql/engine.rb: -------------------------------------------------------------------------------- 1 | module ActiveStorage 2 | module PostgreSQL 3 | class Engine < ::Rails::Engine 4 | isolate_namespace ActiveStorage::PostgreSQL 5 | 6 | railtie_name 'active_storage_postgresql' 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/active_storage/postgresql/file.rb: -------------------------------------------------------------------------------- 1 | class ActiveStorage::PostgreSQL::File < ActiveRecord::Base 2 | self.table_name = "active_storage_postgresql_files" 3 | 4 | attribute :oid, :integer, default: ->{ connection.raw_connection.lo_creat } 5 | attr_accessor :checksum, :io 6 | attr_writer :digest 7 | 8 | def digest 9 | @digest ||= Digest::MD5.new 10 | end 11 | 12 | before_create :write_or_import, if: :io 13 | before_create :verify_checksum, if: :checksum 14 | 15 | def write_or_import 16 | if io.respond_to?(:to_path) 17 | import(io.to_path) 18 | else 19 | open(::PG::INV_WRITE) do |file| 20 | while data = io.read(5.megabytes) 21 | write(data) 22 | end 23 | end 24 | end 25 | end 26 | 27 | def verify_checksum 28 | raise ActiveStorage::IntegrityError unless digest.base64digest == checksum 29 | end 30 | 31 | def self.open(key, &block) 32 | find_by!(key: key).open(&block) 33 | end 34 | 35 | def open(*args) 36 | transaction do 37 | begin 38 | @lo = lo_open(oid, *args) 39 | yield(self) 40 | ensure 41 | lo_close(@lo) if @lo 42 | end 43 | end 44 | end 45 | 46 | def write(content) 47 | lo_write(@lo, content) 48 | digest.update(content) 49 | end 50 | 51 | def read(bytes=size) 52 | lo_read(@lo, bytes) 53 | end 54 | 55 | def seek(position, whence=PG::SEEK_SET) 56 | lo_seek(@lo, position, whence) 57 | end 58 | 59 | def import(path) 60 | self.oid = lo_import(path) 61 | self.digest = Digest::MD5.file(path) 62 | end 63 | 64 | def tell 65 | lo_tell(@lo) 66 | end 67 | 68 | def size 69 | current_position = tell 70 | seek(0, PG::SEEK_END) 71 | tell.tap do 72 | seek(current_position) 73 | end 74 | end 75 | 76 | def unlink 77 | lo_unlink(oid) 78 | end 79 | 80 | before_destroy :unlink 81 | 82 | delegate :lo_seek, :lo_tell, :lo_import, :lo_read, :lo_write, :lo_open, 83 | :lo_unlink, :lo_close, :lo_creat, to: 'self.class.connection.raw_connection' 84 | 85 | scope :prefixed_with, -> prefix { where("key like ?", "#{prefix}%") } 86 | 87 | end 88 | -------------------------------------------------------------------------------- /lib/active_storage/postgresql/version.rb: -------------------------------------------------------------------------------- 1 | module ActiveStorage 2 | module PostgreSQL 3 | VERSION = '0.3.1' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/active_storage/service/postgre_sql_service.rb: -------------------------------------------------------------------------------- 1 | require 'active_storage/service/postgresql_service' 2 | -------------------------------------------------------------------------------- /lib/active_storage/service/postgresql_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveStorage 4 | # Wraps a PostgreSQL database as an Active Storage service. See ActiveStorage::Service for the generic API 5 | # documentation that applies to all services. 6 | class Service::PostgreSQLService < Service 7 | def initialize(public: false, **options) 8 | @public = public 9 | end 10 | 11 | def upload(key, io, checksum: nil, **) 12 | instrument :upload, key: key, checksum: checksum do 13 | ActiveStorage::PostgreSQL::File.create!(key: key, io: io, checksum: checksum) 14 | end 15 | end 16 | 17 | def download(key) 18 | if block_given? 19 | instrument :streaming_download, key: key do 20 | ActiveStorage::PostgreSQL::File.open(key) do |file| 21 | while data = file.read(5.megabytes) 22 | yield data 23 | end 24 | end 25 | end 26 | else 27 | instrument :download, key: key do 28 | ActiveStorage::PostgreSQL::File.open(key) do |file| 29 | file.read 30 | end 31 | end 32 | end 33 | end 34 | 35 | def download_chunk(key, range) 36 | instrument :download_chunk, key: key, range: range do 37 | ActiveStorage::PostgreSQL::File.open(key) do |file| 38 | file.seek(range.first) 39 | file.read(range.size) 40 | end 41 | end 42 | end 43 | 44 | def delete(key) 45 | instrument :delete, key: key do 46 | ActiveStorage::PostgreSQL::File.find_by(key: key).try(&:destroy) 47 | end 48 | end 49 | 50 | def exist?(key) 51 | instrument :exist, key: key do |payload| 52 | answer = ActiveStorage::PostgreSQL::File.where(key: key).exists? 53 | payload[:exist] = answer 54 | answer 55 | end 56 | end 57 | 58 | def delete_prefixed(prefix) 59 | instrument :delete_prefixed, prefix: prefix do 60 | ActiveStorage::PostgreSQL::File.prefixed_with(prefix).destroy_all 61 | end 62 | end 63 | 64 | def private_url(key, expires_in:, filename:, content_type:, disposition:, **) 65 | generate_url(key, expires_in: expires_in, filename: filename, content_type: content_type, disposition: disposition) 66 | end 67 | 68 | def public_url(key, filename:, content_type: nil, disposition: :attachment, **) 69 | generate_url(key, expires_in: nil, filename: filename, content_type: content_type, disposition: disposition) 70 | end 71 | 72 | def url(key, **options) 73 | super 74 | rescue NotImplementedError, ArgumentError 75 | if @public 76 | public_url(key, **options) 77 | else 78 | private_url(key, **options) 79 | end 80 | end 81 | 82 | def generate_url(key, expires_in:, filename:, disposition:, content_type:) 83 | instrument :url, key: key do |payload| 84 | content_disposition = content_disposition_with(type: disposition, filename: filename) 85 | verified_key_with_expiration = ActiveStorage.verifier.generate( 86 | { 87 | key: key, 88 | disposition: content_disposition, 89 | content_type: content_type 90 | }, 91 | expires_in: expires_in, 92 | purpose: :blob_key 93 | ) 94 | 95 | generated_url = url_helpers.rails_postgresql_service_url(verified_key_with_expiration, 96 | **url_options, 97 | disposition: content_disposition, 98 | content_type: content_type, 99 | filename: filename 100 | ) 101 | payload[:url] = generated_url 102 | 103 | generated_url 104 | end 105 | end 106 | 107 | def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:, custom_metadata: {}) 108 | instrument :url, key: key do |payload| 109 | verified_token_with_expiration = ActiveStorage.verifier.generate( 110 | { 111 | key: key, 112 | content_type: content_type, 113 | content_length: content_length, 114 | checksum: checksum 115 | }, 116 | expires_in: expires_in, 117 | purpose: :blob_token 118 | ) 119 | 120 | generated_url = url_helpers.update_rails_postgresql_service_url(verified_token_with_expiration, **url_options) 121 | 122 | payload[:url] = generated_url 123 | 124 | generated_url 125 | end 126 | end 127 | 128 | def headers_for_direct_upload(key, content_type:, **) 129 | { "Content-Type" => content_type } 130 | end 131 | 132 | protected 133 | 134 | def url_helpers 135 | @url_helpers ||= Rails.application.routes.url_helpers 136 | end 137 | 138 | def url_options 139 | if ActiveStorage::Current.respond_to?(:url_options) 140 | ActiveStorage::Current.url_options 141 | else 142 | { host: ActiveStorage::Current.host } 143 | end 144 | end 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /lib/tasks/active_storage/postgresql_tasks.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :active_storage do 4 | namespace :postgresql do 5 | desc "Copy over the migration needed to the application" 6 | task install: :environment do 7 | if Rake::Task.task_defined?("active_storage_postgresql:install:migrations") 8 | Rake::Task["active_storage_postgresql:install:migrations"].invoke 9 | else 10 | Rake::Task["app:active_storage_postgresql:install:migrations"].invoke 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/active_storage/postgresql_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_storage/shared_service_tests" 4 | class ActiveStorage::Service::PostgreSQLServiceTest < ActiveSupport::TestCase 5 | self.use_transactional_tests = false 6 | SERVICE = ActiveStorage::Service.configure(:postgresql, {postgresql: {service: "PostgreSQL"}}) 7 | 8 | setup do 9 | if ActiveStorage::Current.respond_to?(:url_options=) 10 | ActiveStorage::Current.url_options = {host: "https://example.com", protocol: "https"} 11 | else 12 | ActiveStorage::Current.host = "https://example.com" 13 | end 14 | end 15 | 16 | teardown do 17 | ActiveStorage::Current.reset 18 | end 19 | 20 | include ActiveStorage::Service::SharedServiceTests 21 | 22 | test "uploading with service metadata" do 23 | begin 24 | key = SecureRandom.base58(24) 25 | data = "Something else entirely!" 26 | @service.upload(key, StringIO.new(data), checksum: Digest::MD5.base64digest(data), irrelevant_metadata: "ignored") 27 | assert_equal data, @service.download(key) 28 | ensure 29 | @service.delete key 30 | end 31 | end 32 | 33 | test "uploading file with integrity" do 34 | begin 35 | key = SecureRandom.base58(24) 36 | data = "Something else entirely!" 37 | file = Tempfile.open("upload") 38 | file.write(data) 39 | file.rewind 40 | @service.upload(key, file, checksum: Digest::MD5.base64digest(data)) 41 | assert_equal data, @service.download(key) 42 | ensure 43 | @service.delete key 44 | end 45 | end 46 | 47 | test "uploading file without integrity" do 48 | begin 49 | key = SecureRandom.base58(24) 50 | data = "Something else entirely!" 51 | file = Tempfile.open("upload") 52 | file.write(data) 53 | file.rewind 54 | 55 | assert_raises(ActiveStorage::IntegrityError) do 56 | @service.upload(key, file, checksum: Digest::MD5.base64digest("bad data")) 57 | end 58 | 59 | assert_not @service.exist?(key) 60 | ensure 61 | @service.delete key 62 | end 63 | end 64 | 65 | test "url generation" do 66 | assert_match(/^https:\/\/example.com\/rails\/active_storage\/postgresql\/.*\/avatar\.png\?content_type=image%2Fpng&disposition=inline/, 67 | @service.url(FIXTURE_KEY, expires_in: 5.minutes, disposition: :inline, filename: ActiveStorage::Filename.new("avatar.png"), content_type: "image/png")) 68 | end 69 | 70 | test "headers_for_direct_upload generation" do 71 | assert_equal({ "Content-Type" => "application/json" }, @service.headers_for_direct_upload(FIXTURE_KEY, content_type: "application/json")) 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /test/active_storage/public_postgresql_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_storage/shared_service_tests" 4 | require "net/http" 5 | 6 | class ActiveStorage::Service::PostgreSQLPublicServiceTest < ActiveSupport::TestCase 7 | tmp_config = { 8 | tmp_public: { service: "PostgreSQL", public: true } 9 | } 10 | SERVICE = ActiveStorage::Service.configure(:tmp_public, tmp_config) 11 | 12 | include ActiveStorage::Service::SharedServiceTests 13 | 14 | test "public URL generation" do 15 | url = @service.url(@key, disposition: :inline, filename: ActiveStorage::Filename.new("avatar.png"), content_type: "image/png") 16 | 17 | assert_match(/^https:\/\/example.com\/rails\/active_storage\/postgresql\/.*\/avatar\.png/, url) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/active_storage/shared_service_tests.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Copied from ActiveStorage 4 | 5 | require "test_helper" 6 | require "active_support/core_ext/securerandom" 7 | 8 | module ActiveStorage::Service::SharedServiceTests 9 | extend ActiveSupport::Concern 10 | 11 | FIXTURE_KEY = SecureRandom.base58(24) 12 | FIXTURE_DATA = "\211PNG\r\n\032\n\000\000\000\rIHDR\000\000\000\020\000\000\000\020\001\003\000\000\000%=m\"\000\000\000\006PLTE\000\000\000\377\377\377\245\331\237\335\000\000\0003IDATx\234c\370\377\237\341\377_\206\377\237\031\016\2603\334?\314p\1772\303\315\315\f7\215\031\356\024\203\320\275\317\f\367\201R\314\f\017\300\350\377\177\000Q\206\027(\316]\233P\000\000\000\000IEND\256B`\202".dup.force_encoding(Encoding::BINARY) 13 | 14 | included do 15 | setup do 16 | @service = self.class.const_get(:SERVICE) 17 | @service.upload FIXTURE_KEY, StringIO.new(FIXTURE_DATA) 18 | end 19 | 20 | teardown do 21 | @service.delete FIXTURE_KEY 22 | end 23 | 24 | test "uploading with integrity" do 25 | begin 26 | key = SecureRandom.base58(24) 27 | data = "Something else entirely!" 28 | @service.upload(key, StringIO.new(data), checksum: Digest::MD5.base64digest(data)) 29 | 30 | assert_equal data, @service.download(key) 31 | ensure 32 | @service.delete key 33 | end 34 | end 35 | 36 | test "uploading without integrity" do 37 | begin 38 | key = SecureRandom.base58(24) 39 | data = "Something else entirely!" 40 | 41 | assert_raises(ActiveStorage::IntegrityError) do 42 | @service.upload(key, StringIO.new(data), checksum: Digest::MD5.base64digest("bad data")) 43 | end 44 | 45 | assert_not @service.exist?(key) 46 | ensure 47 | @service.delete key 48 | end 49 | end 50 | 51 | test "downloading" do 52 | assert_equal FIXTURE_DATA, @service.download(FIXTURE_KEY) 53 | end 54 | 55 | test "downloading in chunks" do 56 | key = SecureRandom.base58(24) 57 | expected_chunks = [ "a" * 5.megabytes, "b" ] 58 | actual_chunks = [] 59 | 60 | begin 61 | @service.upload key, StringIO.new(expected_chunks.join) 62 | 63 | @service.download key do |chunk| 64 | actual_chunks << chunk 65 | end 66 | 67 | assert_equal expected_chunks, actual_chunks, "Downloaded chunks did not match uploaded data" 68 | ensure 69 | @service.delete key 70 | end 71 | end 72 | 73 | test "downloading partially" do 74 | assert_equal "\x10\x00\x00", @service.download_chunk(FIXTURE_KEY, 19..21) 75 | assert_equal "\x10\x00\x00", @service.download_chunk(FIXTURE_KEY, 19...22) 76 | end 77 | 78 | test "existing" do 79 | assert @service.exist?(FIXTURE_KEY) 80 | assert_not @service.exist?(FIXTURE_KEY + "nonsense") 81 | end 82 | 83 | test "deleting" do 84 | @service.delete FIXTURE_KEY 85 | assert_not @service.exist?(FIXTURE_KEY) 86 | end 87 | 88 | test "deleting nonexistent key" do 89 | assert_nothing_raised do 90 | @service.delete SecureRandom.base58(24) 91 | end 92 | end 93 | 94 | test "deleting by prefix" do 95 | begin 96 | @service.upload("a/a/a", StringIO.new(FIXTURE_DATA)) 97 | @service.upload("a/a/b", StringIO.new(FIXTURE_DATA)) 98 | @service.upload("a/b/a", StringIO.new(FIXTURE_DATA)) 99 | @service.delete_prefixed("a/a/") 100 | assert_not @service.exist?("a/a/a") 101 | assert_not @service.exist?("a/a/b") 102 | assert @service.exist?("a/b/a") 103 | ensure 104 | @service.delete("a/a/a") 105 | @service.delete("a/a/b") 106 | @service.delete("a/b/a") 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /test/controllers/postgresql_controller_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ActiveStorage::PostgresqlControllerTest < ActionDispatch::IntegrationTest 6 | 7 | test "showing blob inline" do 8 | blob = create_blob(filename: "hello.jpg", content_type: "image/jpeg") 9 | 10 | get blob.send(url_method) 11 | assert_response :ok 12 | assert_equal "inline; filename=\"hello.jpg\"; filename*=UTF-8''hello.jpg", response.headers["Content-Disposition"] 13 | assert_equal "image/jpeg", response.headers["Content-Type"] 14 | assert_equal "Hello world!", response.body 15 | end 16 | 17 | test "showing blob as attachment" do 18 | blob = create_blob 19 | get blob.send(url_method, disposition: :attachment) 20 | 21 | assert_response :ok 22 | assert_equal "attachment; filename=\"hello.txt\"; filename*=UTF-8''hello.txt", response.headers["Content-Disposition"] 23 | assert_equal "text/plain", response.headers["Content-Type"] 24 | assert_equal "Hello world!", response.body 25 | end 26 | 27 | test "showing blob range" do 28 | blob = create_blob 29 | get blob.send(url_method), headers: { "Range" => "bytes=5-9" } 30 | assert_response :partial_content 31 | assert_equal "attachment; filename=\"hello.txt\"; filename*=UTF-8''hello.txt", response.headers["Content-Disposition"] 32 | assert_equal "text/plain", response.headers["Content-Type"] 33 | assert_equal " worl", response.body 34 | end 35 | 36 | test "showing blob with empty range" do 37 | blob = create_blob 38 | get blob.send(url_method), headers: { "Range" => "bytes=100-" } 39 | assert_response 416 40 | end 41 | 42 | test "showing blob that does not exist" do 43 | blob = create_blob 44 | blob.delete 45 | 46 | get blob.send(url_method) 47 | end 48 | 49 | test "showing blob with invalid key" do 50 | get rails_postgresql_service_url(encoded_key: "Invalid key", filename: "hello.txt") 51 | assert_response :not_found 52 | end 53 | 54 | test "not allowing to set disposition from params" do 55 | blob = create_blob(filename: "hello.jpg", content_type: "image/jpeg") 56 | 57 | get blob.send(url_method), params: { disposition: :attachment } 58 | assert_response :ok 59 | assert_equal "inline; filename=\"hello.jpg\"; filename*=UTF-8''hello.jpg", response.headers["Content-Disposition"] 60 | end 61 | 62 | test "not allowing to set content-type from params" do 63 | blob = create_blob(filename: "hello.jpg", content_type: "image/jpeg") 64 | 65 | get blob.send(url_method), params: { content_type: 'text/html' } 66 | assert_response :ok 67 | assert_equal "image/jpeg", response.headers["Content-Type"] 68 | end 69 | 70 | 71 | test "directly uploading blob with integrity" do 72 | data = "Something else entirely!" 73 | blob = create_blob_before_direct_upload byte_size: data.size, checksum: Digest::MD5.base64digest(data) 74 | 75 | put blob.service_url_for_direct_upload, params: data, headers: { "Content-Type" => "text/plain" } 76 | assert_response :no_content 77 | assert_equal data, blob.download 78 | end 79 | 80 | test "directly uploading blob without integrity" do 81 | data = "Something else entirely!" 82 | blob = create_blob_before_direct_upload byte_size: data.size, checksum: Digest::MD5.base64digest("bad data") 83 | 84 | put blob.service_url_for_direct_upload, params: data 85 | assert_response :unprocessable_entity 86 | assert_not blob.service.exist?(blob.key) 87 | end 88 | 89 | test "directly uploading blob with mismatched content type" do 90 | data = "Something else entirely!" 91 | blob = create_blob_before_direct_upload byte_size: data.size, checksum: Digest::MD5.base64digest(data) 92 | 93 | put blob.service_url_for_direct_upload, params: data, headers: { "Content-Type" => "application/octet-stream" } 94 | assert_response :unprocessable_entity 95 | assert_not blob.service.exist?(blob.key) 96 | end 97 | 98 | test "directly uploading blob with different but equivalent content type" do 99 | data = "Something else entirely!" 100 | blob = create_blob_before_direct_upload( 101 | byte_size: data.size, checksum: Digest::MD5.base64digest(data), content_type: "application/x-gzip") 102 | 103 | put blob.service_url_for_direct_upload, params: data, headers: { "Content-Type" => "application/x-gzip" } 104 | assert_response :no_content 105 | assert_equal data, blob.download 106 | end 107 | 108 | test "directly uploading blob with mismatched content length" do 109 | data = "Something else entirely!" 110 | blob = create_blob_before_direct_upload byte_size: data.size - 1, checksum: Digest::MD5.base64digest(data) 111 | 112 | put blob.service_url_for_direct_upload, params: data, headers: { "Content-Type" => "text/plain" } 113 | assert_response :unprocessable_entity 114 | assert_not blob.service.exist?(blob.key) 115 | end 116 | 117 | 118 | test "showing public blob" do 119 | with_service(:local_public) do 120 | blob = create_blob(content_type: "image/jpeg") 121 | 122 | get blob.send(url_method) 123 | assert_response :ok 124 | assert_equal "image/jpeg", response.headers["Content-Type"] 125 | assert_equal "Hello world!", response.body 126 | end 127 | end 128 | 129 | test "showing public blob variant" do 130 | with_service(:local_public) do 131 | blob = create_file_blob.variant(resize_to_limit: [100, 100]).processed 132 | 133 | get blob.blob.send(url_method) 134 | assert_response :ok 135 | assert_equal "image/jpeg", response.headers["Content-Type"] 136 | end 137 | end 138 | 139 | test "directly uploading blob with invalid token" do 140 | put update_rails_postgresql_service_url(encoded_token: "invalid"), 141 | params: "Something else entirely!", headers: { "Content-Type" => "text/plain" } 142 | assert_response :not_found 143 | end 144 | 145 | def url_method 146 | ActiveStorage::Blob.method_defined?(:url) ? :url : :service_url 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /test/dummy/.ruby-version: -------------------------------------------------------------------------------- 1 | 2.5.0 -------------------------------------------------------------------------------- /test/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative 'config/application' 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /test/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../javascripts .js 3 | //= link_directory ../stylesheets .css 4 | -------------------------------------------------------------------------------- /test/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lsylvester/active_storage-postgresql/fa5bc8fc1882e1877d88fe557962b1126ea9c60d/test/dummy/app/assets/images/.keep -------------------------------------------------------------------------------- /test/dummy/app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. JavaScript code in this file should be added after the last require_* statement. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require rails-ujs 14 | //= require activestorage 15 | //= require_tree . 16 | -------------------------------------------------------------------------------- /test/dummy/app/assets/javascripts/cable.js: -------------------------------------------------------------------------------- 1 | // Action Cable provides the framework to deal with WebSockets in Rails. 2 | // You can generate new channels where WebSocket features live using the `rails generate channel` command. 3 | // 4 | //= require action_cable 5 | //= require_self 6 | //= require_tree ./channels 7 | 8 | (function() { 9 | this.App || (this.App = {}); 10 | 11 | App.cable = ActionCable.createConsumer(); 12 | 13 | }).call(this); 14 | -------------------------------------------------------------------------------- /test/dummy/app/assets/javascripts/channels/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lsylvester/active_storage-postgresql/fa5bc8fc1882e1877d88fe557962b1126ea9c60d/test/dummy/app/assets/javascripts/channels/.keep -------------------------------------------------------------------------------- /test/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /test/dummy/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lsylvester/active_storage-postgresql/fa5bc8fc1882e1877d88fe557962b1126ea9c60d/test/dummy/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /test/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: 'from@example.com' 3 | layout 'mailer' 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lsylvester/active_storage-postgresql/fa5bc8fc1882e1877d88fe557962b1126ea9c60d/test/dummy/app/models/concerns/.keep -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |You may have mistyped the address or the page may have moved.
63 |If you are the application owner check the logs for more information.
65 |Maybe you tried to change something you didn't have access to.
63 |If you are the application owner check the logs for more information.
65 |If you are the application owner check the logs for more information.
64 |