diff --git a/.gitignore b/.gitignore index 68ea67190..f14f3fac7 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ tmp # docker-compose database directory docker-db-data + +/config/credentials/ diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 000000000..cc19e09fb --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,59 @@ +FROM ruby:3.2-bookworm + +ENV DEBIAN_FRONTEND=noninteractive + +# Install system packages then clean up to minimize image size +RUN apt-get update \ + && apt-get install --no-install-recommends -y \ + build-essential \ + curl \ + default-jre-headless \ + file \ + git-core \ + gpg-agent \ + libarchive-dev \ + libffi-dev \ + libgd-dev \ + libpq-dev \ + libsasl2-dev \ + libvips-dev \ + libxml2-dev \ + libxslt1-dev \ + libyaml-dev \ + locales \ + postgresql-client \ + tzdata \ + unzip \ + nodejs \ + npm \ + osmosis \ + ca-certificates \ + firefox-esr + +# Install yarn globally +RUN npm install --global yarn + +# Add support for Postgres 16 +RUN apt-get install --no-install-recommends -y postgresql-common \ + && /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y \ + && apt-get install --no-install-recommends -y postgresql-client-16 + +# Setup app location +RUN mkdir -p /app +WORKDIR /app +COPY . . + +COPY config/example.storage.yml config/storage.yml + +# https://help.openstreetmap.org/questions/69887/actionviewtemplateerror-couldnt-find-file-settingslocalyml +RUN touch config/settings.local.yml + +# Install Ruby packages +RUN bundle install + +# Install NodeJS packages using yarn +RUN bundle exec bin/yarn install + +# Build frontend assets +RUN RAILS_ENV=production SECRET_KEY_BASE=dummy bundle exec i18n export +RUN RAILS_ENV=production SECRET_KEY_BASE=dummy rails assets:precompile diff --git a/Gemfile b/Gemfile index 7286f4ed6..ee59832f2 100644 --- a/Gemfile +++ b/Gemfile @@ -135,6 +135,9 @@ gem "inline_svg" # Used to validate widths gem "unicode-display_width" +# TDEI Workspaces: supports multi-tenancy +gem "ros-apartment", :require => "apartment" + # Gems useful for development group :development do gem "better_errors" diff --git a/Gemfile.lock b/Gemfile.lock index e1a39c2b1..22204f46c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -501,7 +501,7 @@ GEM psych (5.2.6) date stringio - public_suffix (6.0.2) + public_suffix (6.0.1) puma (6.6.1) nio4r (~> 2.0) quad_tile (1.0.1) @@ -577,6 +577,12 @@ GEM rack (>= 1.4) rexml (3.4.2) rinku (2.0.6) + ros-apartment (3.2.0) + activerecord (>= 6.1.0, < 8.1) + activesupport (>= 6.1.0, < 8.1) + parallel (< 2.0) + public_suffix (>= 2.0.5, <= 6.0.1) + rack (>= 1.3.6, < 4.0) rotp (6.3.0) rouge (4.6.0) rtlcss (0.2.1) @@ -788,6 +794,7 @@ DEPENDENCIES rails-i18n (~> 8.0.0) rails_param rinku (>= 2.0.6) + ros-apartment rotp rtlcss rubocop diff --git a/app/controllers/api/users_controller.rb b/app/controllers/api/users_controller.rb index 58b02a489..00c7487f6 100644 --- a/app/controllers/api/users_controller.rb +++ b/app/controllers/api/users_controller.rb @@ -3,7 +3,8 @@ class UsersController < ApiController before_action :setup_user_auth, :only => [:show, :index] before_action -> { authorize(:skip_terms => true) }, :only => [:details] - authorize_resource + authorize_resource :except => [:provision] + skip_authorization_check :only => [:provision] load_resource :only => :show @@ -45,5 +46,64 @@ def details format.json { render :show } end end + + def provision + user = User.find_by(:auth_provider => "TDEI", :auth_uid => params[:auth_uid]) + + if user + user.email = params[:email] + user.display_name = params[:display_name] + + if user.status != "active" + user.activate + end + + user.save + return head :no_content + end + + # TODO: temporary for TDEI auth transition: + user = User.find_by(:email => params[:email]) + + if user + user.auth_provider = 'TDEI' + user.auth_uid = params[:auth_uid] + user.display_name = params[:display_name] + + if user.status != "active" + user.activate + end + + user.save + return head :no_content + end + + puts 'Create user' + user = User.new() + user.email = params[:email] + user.email_valid = true + user.display_name = params[:display_name] + + user.auth_provider = 'TDEI' + user.auth_uid = params[:auth_uid] + + # Set a random password--TDEI identity services manage the password: + user.pass_crypt = SecureRandom.base64(16) + + user.data_public = true + user.description = '' if user.description.nil? + user.creation_address = request.remote_ip + user.languages = http_accept_language.user_preferred_languages + user.terms_agreed = Time.now.utc + user.tou_agreed = Time.now.utc + user.terms_seen = true + user.auth_provider = nil + user.auth_uid = nil + user.activate + user.save + + head :no_content + end + end end diff --git a/app/controllers/api/workspaces_controller.rb b/app/controllers/api/workspaces_controller.rb new file mode 100644 index 000000000..585816f57 --- /dev/null +++ b/app/controllers/api/workspaces_controller.rb @@ -0,0 +1,72 @@ +# TODO: this controller needs special auth with the workspaces backend +module Api + class WorkspacesController < ApiController + before_action :check_api_readable + before_action :set_request_formats + #before_action :authorize + + #authorize_resource + skip_authorization_check + + around_action :api_call_handle_error, :api_call_timeout + + def create + workspace_schema = 'workspace-' + params[:id] + + # This runs the migrations that scaffold all of the OSM tables into a new + # schema. We don't need every table, but we can't choose. The global non- + # tenant tables match excluded models in config/initializers/apartment.rb + # that Apartment associates with the "public" schema. + # + Apartment::Tenant.create(workspace_schema) + Apartment::Tenant.switch!(workspace_schema) + + # Drop tables that shadow the "public" schema. These tables cause foreign + # key constraint issues for tenant tables. The migrations include all OSM + # tables, and any unqualified references resolve to tenant schema tables. + # Removing these tables from the search path causes PostgreSQL to resolve + # tables references from the "public" schema instead: + # + connection = ActiveRecord::Base.connection + connection.execute("DROP TABLE \"#{workspace_schema}\".users CASCADE") + end + + def switch + cookies[:workspace] = { + :value => params[:id], + :expires => 1.year.from_now, + } + end + + def destroy + Apartment::Tenant.drop('workspace-' + params[:id]) + end + + def bbox + workspace_schema = 'workspace-' + params[:id] + Apartment::Tenant.switch!(workspace_schema) + + out = Node + .select('MAX(latitude) AS max_lat, + MAX(longitude) AS max_lon, + MIN(latitude) AS min_lat, + MIN(longitude) AS min_lon') + .take + + if out.min_lat.nil? # Workspace is empty (no nodes) + return head :no_content + end + + @bbox = BoundingBox.new(out.min_lon, out.min_lat, out.max_lon, out.max_lat) + + respond_to do |format| + format.xml + format.json + end + end + end +end + +# TODO stub +class Workspace +end diff --git a/app/middleware/workspaces_elevator.rb b/app/middleware/workspaces_elevator.rb new file mode 100644 index 000000000..0162b9f43 --- /dev/null +++ b/app/middleware/workspaces_elevator.rb @@ -0,0 +1,20 @@ +require 'apartment/elevators/generic' + +class WorkspacesElevator < Apartment::Elevators::Generic + def parse_tenant_name(request) + return nil if request.path.match? /^\/api\/[0-9.]+\/workspaces\// + + workspace_id = request.env['HTTP_X_WORKSPACE'] # X-Workspace header + + if workspace_id.blank? + workspace_id = request.cookies['workspace'] + end + + return nil if workspace_id.blank? + return nil unless workspace_id.match? /^\d+$/ + + puts 'Selecting workspace ' + workspace_id + + return 'workspace-' + workspace_id + end +end diff --git a/app/views/api/workspaces/bbox.json.jbuilder b/app/views/api/workspaces/bbox.json.jbuilder new file mode 100644 index 000000000..087a4c62a --- /dev/null +++ b/app/views/api/workspaces/bbox.json.jbuilder @@ -0,0 +1,4 @@ +json.min_lat GeoRecord::Coord.new(@bbox.to_unscaled.min_lat) +json.min_lon GeoRecord::Coord.new(@bbox.to_unscaled.min_lon) +json.max_lat GeoRecord::Coord.new(@bbox.to_unscaled.max_lat) +json.max_lon GeoRecord::Coord.new(@bbox.to_unscaled.max_lon) diff --git a/config/.gitignore b/config/.gitignore index 95ba2dbdb..28d379bd9 100644 --- a/config/.gitignore +++ b/config/.gitignore @@ -1,2 +1 @@ -database.yml storage.yml diff --git a/config/application.rb b/config/application.rb index d60ad4c1b..9b3685e8c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -1,4 +1,5 @@ require_relative "boot" +require_relative "../app/middleware/workspaces_elevator.rb" require "rails/all" @@ -45,5 +46,8 @@ class Application < Rails::Application config.logstasher.logger_path = Settings.logstash_path config.logstasher.log_controller_parameters = true end + + # TDEI Workspaces tenant partitioning: + config.middleware.use WorkspacesElevator end end diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 000000000..5a0bb5b14 --- /dev/null +++ b/config/database.yml @@ -0,0 +1,8 @@ +<%= ENV['RAILS_ENV'] %>: + adapter: postgresql + host: <%= ENV['WS_OSM_DB_HOST'] %> + username: <%= ENV['WS_OSM_DB_USER'] %> + password: <%= ENV['WS_OSM_DB_PASS'] %> + database: <%= ENV['WS_OSM_DB_NAME'] %> + encoding: utf8 + diff --git a/config/initializers/apartment.rb b/config/initializers/apartment.rb new file mode 100644 index 000000000..c250a6b98 --- /dev/null +++ b/config/initializers/apartment.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +# You can have Apartment route to the appropriate Tenant by adding some Rack middleware. +# Apartment can support many different "Elevators" that can take care of this routing to your data. +# Require whichever Elevator you're using below or none if you have a custom one. +# +# require 'apartment/elevators/generic' +# require 'apartment/elevators/domain' +# require 'apartment/elevators/subdomain' +# require 'apartment/elevators/first_subdomain' +# require 'apartment/elevators/host' +require_relative "../../app/middleware/workspaces_elevator.rb" + +# +# Apartment Configuration +# +Apartment.configure do |config| + # Add any models that you do not want to be multi-tenanted, but remain in the global (public) namespace. + # A typical example would be a Customer or Tenant model that stores each Tenant's information. + # + config.excluded_models = [ + "Acl", + "DiaryComment", + "DiaryEntry", + "DiaryEntrySubscription", + "Doorkeeper::AccessGrant", + "Doorkeeper::AccessToken", + "Doorkeeper::Application", + "Issue", + "IssueComment", + "Language", + "Message", + "Oauth2Application", + "Report", + "User", + "UserBlock", + "UserMute", + "UserPreference", + "UserRole" + ] + + # In order to migrate all of your Tenants you need to provide a list of Tenant names to Apartment. + # You can make this dynamic by providing a Proc object to be called on migrations. + # This object should yield either: + # - an array of strings representing each Tenant name. + # - a hash which keys are tenant names, and values custom db config + # (must contain all key/values required in database.yml) + # + # config.tenant_names = lambda{ Customer.pluck(:tenant_name) } + # config.tenant_names = ['tenant1', 'tenant2'] + # config.tenant_names = { + # 'tenant1' => { + # adapter: 'postgresql', + # host: 'some_server', + # port: 5555, + # database: 'postgres' # this is not the name of the tenant's db + # # but the name of the database to connect to before creating the tenant's db + # # mandatory in postgresql + # }, + # 'tenant2' => { + # adapter: 'postgresql', + # database: 'postgres' # this is not the name of the tenant's db + # # but the name of the database to connect to before creating the tenant's db + # # mandatory in postgresql + # } + # } + # config.tenant_names = lambda do + # Tenant.all.each_with_object({}) do |tenant, hash| + # hash[tenant.name] = tenant.db_configuration + # end + # end + # + config.tenant_names = -> { + ActiveRecord::Base.connection.execute("SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'workspace-%';") + .map { |row| row['schema_name'] } + } + + # PostgreSQL: + # Specifies whether to use PostgreSQL schemas or create a new database per Tenant. + # + # MySQL: + # Specifies whether to switch databases by using `use` statement or re-establish connection. + # + # The default behaviour is true. + # + config.use_schemas = true + + # + # ==> PostgreSQL only options + + # Apartment can be forced to use raw SQL dumps instead of schema.rb for creating new schemas. + # Use this when you are using some extra features in PostgreSQL that can't be represented in + # schema.rb, like materialized views etc. (only applies with use_schemas set to true). + # (Note: this option doesn't use db/structure.sql, it creates SQL dump by executing pg_dump) + # + config.use_sql = true + + # There are cases where you might want some schemas to always be in your search_path + # e.g when using a PostgreSQL extension like hstore. + # Any schemas added here will be available along with your selected Tenant. + # + # config.persistent_schemas = %w{ hstore } + + # <== PostgreSQL only options + # + + # By default, and only when not using PostgreSQL schemas, Apartment will prepend the environment + # to the tenant name to ensure there is no conflict between your environments. + # This is mainly for the benefit of your development and test environments. + # Uncomment the line below if you want to disable this behaviour in production. + # + # config.prepend_environment = !Rails.env.production? + + # When using PostgreSQL schemas, the database dump will be namespaced, and + # apartment will substitute the default namespace (usually public) with the + # name of the new tenant when creating a new tenant. Some items must maintain + # a reference to the default namespace (ie public) - for instance, a default + # uuid generation. Uncomment the line below to create a list of namespaced + # items in the schema dump that should *not* have their namespace replaced by + # the new tenant + # + # config.pg_excluded_names = ["uuid_generate_v4"] + + # Specifies whether the database and schema (when using PostgreSQL schemas) will prepend in ActiveRecord log. + # Uncomment the line below if you want to enable this behavior. + # + # config.active_record_log = true +end + +# Setup a custom Tenant switching middleware. The Proc should return the name of the Tenant that +# you want to switch to. +# Rails.application.config.middleware.use Apartment::Elevators::Generic, lambda { |request| +# request.host.split('.').first +# } + +# Rails.application.config.middleware.use Apartment::Elevators::Domain +# Rails.application.config.middleware.use Apartment::Elevators::Subdomain +# Rails.application.config.middleware.use Apartment::Elevators::FirstSubdomain +# Rails.application.config.middleware.use Apartment::Elevators::Host +Rails.application.config.middleware.use WorkspacesElevator diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 75fe503e3..1859c56c5 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -7,12 +7,12 @@ # such as X-Requested-By to requests.) Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do - origins "*" + origins { |source, env| true } resource "/oauth/*", :headers => :any, :methods => [:get, :post] resource "/oauth2/token", :headers => :any, :methods => [:post] resource "/oauth2/revoke", :headers => :any, :methods => [:post] resource "/oauth2/introspect", :headers => :any, :methods => [:post] - resource "/api/*", :headers => :any, :methods => [:get, :post, :put, :delete] + resource "/api/*", :headers => :any, :methods => [:get, :post, :put, :delete], :credentials => true resource "/diary/rss", :headers => :any, :methods => [:get] resource "/diary/*/rss", :headers => :any, :methods => [:get] resource "/trace/*/data", :headers => :any, :methods => [:get] diff --git a/config/routes.rb b/config/routes.rb index bfbc1477b..830bb2de8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -133,6 +133,12 @@ namespace :user_blocks, :path => "user/blocks" do resource :active_list, :path => "active", :only => :show end + + # TDEI Workspaces + put "user/:auth_uid" => "users#provision" + put "workspaces/:id" => "workspaces#create", :id => /\d+/ + delete "workspaces/:id" => "workspaces#destroy", :id => /\d+/ + get "workspaces/:id/bbox" => "workspaces#bbox", :id => /\d+/ end # Data browsing diff --git a/config/settings/development.yml b/config/settings/development.yml index e69de29bb..b921bec33 100644 --- a/config/settings/development.yml +++ b/config/settings/development.yml @@ -0,0 +1,14 @@ +server_protocol: "https" +server_url: <%= ENV['WS_OSM_HOST'] %> + +support_email: <%= ENV['WS_MAIL_CONTACT'] %> +email_from: <%= ENV['WS_MAIL_NAME'] %> <<%= ENV['WS_MAIL_FROM'] %>> +email_return_path: <%= ENV['WS_MAIL_RETURN_PATH'] %> + +smtp_address: <%= ENV['WS_SMTP_HOST'] %> +smtp_port: <%= ENV['WS_SMTP_PORT'] %> +smtp_domain: <%= ENV['WS_SMTP_DOMAIN'] %> +smtp_user_name: <%= ENV['WS_SMTP_USER'] %> +smtp_password: <%= ENV['WS_SMTP_PASS'] %> +smtp_authentication: "plain" +smtp_enable_starttls_auto: true diff --git a/config/settings/production.yml b/config/settings/production.yml index e69de29bb..b921bec33 100644 --- a/config/settings/production.yml +++ b/config/settings/production.yml @@ -0,0 +1,14 @@ +server_protocol: "https" +server_url: <%= ENV['WS_OSM_HOST'] %> + +support_email: <%= ENV['WS_MAIL_CONTACT'] %> +email_from: <%= ENV['WS_MAIL_NAME'] %> <<%= ENV['WS_MAIL_FROM'] %>> +email_return_path: <%= ENV['WS_MAIL_RETURN_PATH'] %> + +smtp_address: <%= ENV['WS_SMTP_HOST'] %> +smtp_port: <%= ENV['WS_SMTP_PORT'] %> +smtp_domain: <%= ENV['WS_SMTP_DOMAIN'] %> +smtp_user_name: <%= ENV['WS_SMTP_USER'] %> +smtp_password: <%= ENV['WS_SMTP_PASS'] %> +smtp_authentication: "plain" +smtp_enable_starttls_auto: true diff --git a/script/create-workspace b/script/create-workspace new file mode 100644 index 000000000..9846ee783 --- /dev/null +++ b/script/create-workspace @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby + +require File.join(File.dirname(__FILE__), "..", "config", "environment") + + diff --git a/yarn.lock b/yarn.lock index 36173bff6..b437fb019 100644 --- a/yarn.lock +++ b/yarn.lock @@ -185,12 +185,12 @@ integrity sha512-Rv5IaFyh9GuFu2BEAGvVvlVGmXt5Jbjd0XhIu0D4Tek81DPQEQryIIwDFTDR27W4NyAtozy8QltB+8SFr2C7mQ== "@stylistic/eslint-plugin@^5.2.3": - version "5.2.3" - resolved "https://registry.yarnpkg.com/@stylistic/eslint-plugin/-/eslint-plugin-5.2.3.tgz#f2be5d25e768f5ef4bb72d339bb71c500accef61" - integrity sha512-oY7GVkJGVMI5benlBDCaRrSC1qPasafyv5dOBLLv5MTilMGnErKhO6ziEfodDDIZbo5QxPUNW360VudJOFODMw== + version "5.3.1" + resolved "https://registry.yarnpkg.com/@stylistic/eslint-plugin/-/eslint-plugin-5.3.1.tgz#1aead935023b708ca6a27d079b1a96b726a38fe2" + integrity sha512-Ykums1VYonM0TgkD0VteVq9mrlO2FhF48MDJnPyv3MktIB2ydtuhlO0AfWm7xnW1kyf5bjOqA6xc7JjviuVTxg== dependencies: "@eslint-community/eslint-utils" "^4.7.0" - "@typescript-eslint/types" "^8.38.0" + "@typescript-eslint/types" "^8.41.0" eslint-visitor-keys "^4.2.1" espree "^10.4.0" estraverse "^5.3.0" @@ -244,10 +244,10 @@ dependencies: "@types/geojson" "*" -"@typescript-eslint/types@^8.38.0": - version "8.39.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.39.0.tgz#80f010b7169d434a91cd0529d70a528dbc9c99c6" - integrity sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg== +"@typescript-eslint/types@^8.41.0": + version "8.41.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.41.0.tgz#9935afeaae65e535abcbcee95383fa649c64d16d" + integrity sha512-9EwxsWdVqh42afLbHP90n2VdHaWU/oWgbH2P0CfcNfdKL7CuKpwMQGjwev56vWu9cSKU7FWSu6r9zck6CVfnag== acorn-jsx@^5.3.2: version "5.3.2"