Merge branch 'master' into feature/btcpay_configs
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
Release Drafter / Update release notes draft (pull_request) Successful in 4s

This commit is contained in:
Râu Cao committed 2023-09-20 19:12:24 +02:00
commit d939f5d649
30 files changed
+352 -41

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ steps:
branch:
- master
- name: rspec
image: guildeducation/rails:2.7.2-14.20.0
image: gitea.kosmos.org/kosmos/akkounts-ci:0.1.0
environment:
RAILS_ENV: test
REDIS_URL: redis://redis:6379/0
+1
View File
@@ -23,6 +23,7 @@
!/tmp/pids/
!/tmp/pids/.keep
/storage
/public/assets
.byebug_history
+1 -1
View File
@@ -4,7 +4,7 @@ FROM ruby:2.7.6
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN apt-get update -qq && apt-get install -y --no-install-recommends curl \
ldap-utils tini
ldap-utils tini libvips
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
RUN apt-get update && apt-get install -y nodejs
+1
View File
@@ -37,6 +37,7 @@ gem 'devise_ldap_authenticatable'
gem 'net-ldap'
# Utilities
gem "image_processing", "~> 1.12.2"
gem "rqrcode", "~> 2.0"
gem 'rails-settings-cached', '~> 2.8.3'
gem 'pagy', '~> 6.0', '>= 6.0.2'
+7
View File
@@ -182,6 +182,9 @@ GEM
hashdiff (1.0.1)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
importmap-rails (1.1.6)
actionpack (>= 6.0.0)
railties (>= 6.0.0)
@@ -220,6 +223,7 @@ GEM
marcel (1.0.2)
matrix (0.4.2)
method_source (1.0.0)
mini_magick (4.12.0)
mini_mime (1.1.2)
minitest (5.18.0)
multipart-post (2.3.0)
@@ -337,6 +341,8 @@ GEM
rubocop-ast (1.29.0)
parser (>= 3.2.1.0)
ruby-progressbar (1.13.0)
ruby-vips (2.1.4)
ffi (~> 1.12)
ruby2_keywords (0.0.5)
rufus-scheduler (3.9.1)
fugit (~> 1.1, >= 1.1.6)
@@ -435,6 +441,7 @@ DEPENDENCIES
flipper
flipper-active_record
flipper-ui
image_processing (~> 1.12.2)
importmap-rails
jbuilder (~> 2.7)
letter_opener
@@ -20,6 +20,8 @@ class Admin::UsersController < Admin::BaseController
end
@services_enabled = @user.services_enabled
@avatar = LdapManager::FetchAvatar.call(cn: @user.cn, ou: @user.ou)
end
private
@@ -37,4 +37,8 @@ class ApplicationController < ActionController::Base
format.any { head status }
end
end
def after_sign_in_path_for(user)
session[:user_return_to] || root_path
end
end
+1
View File
@@ -108,6 +108,7 @@ class Rs::OauthController < ApplicationController
def require_signed_in_with_username
unless user_signed_in?
username, org = params[:useraddress].split("@")
session[:user_return_to] = request.url
redirect_to new_user_session_path(cn: username, ou: org)
end
end
+7 -2
View File
@@ -19,10 +19,15 @@ class SettingsController < ApplicationController
def update
@user.preferences.merge!(user_params[:preferences] || {})
@user.display_name = user_params[:display_name]
@user.avatar_new = user_params[:avatar]
if @user.save
if @user.display_name && (@user.display_name != @user.ldap_entry[:display_name])
LdapManager::UpdateDisplayName.call(@user.dn, user_params[:display_name])
LdapManager::UpdateDisplayName.call(@user.dn, @user.display_name)
end
if @user.avatar_new.present?
LdapManager::UpdateAvatar.call(@user.dn, @user.avatar_new)
end
redirect_to setting_path(@settings_section), flash: {
@@ -117,7 +122,7 @@ class SettingsController < ApplicationController
end
def user_params
params.require(:user).permit(:display_name, preferences: [
params.require(:user).permit(:display_name, :avatar, preferences: [
:lightning_notify_sats_received,
:xmpp_exchange_contacts_with_invitees
])
+35
View File
@@ -2,10 +2,14 @@ class User < ApplicationRecord
include EmailValidatable
attr_accessor :display_name
attr_accessor :avatar_new
serialize :preferences, UserPreferences
#
# Relations
#
has_many :invitations, dependent: :destroy
has_one :invitation, inverse_of: :invitee, foreign_key: 'invited_user_id'
has_one :inviter, through: :invitation, source: :user
@@ -20,6 +24,10 @@ class User < ApplicationRecord
has_many :remote_storage_authorizations
#
# Validations
#
validates_uniqueness_of :cn, scope: :ou
validates_length_of :cn, minimum: 3
validates_format_of :cn, with: /\A([a-z0-9\-])*\z/,
@@ -40,10 +48,20 @@ class User < ApplicationRecord
validates_uniqueness_of :nostr_pubkey, allow_blank: true
validate :acceptable_avatar
#
# Scopes
#
scope :confirmed, -> { where.not(confirmed_at: nil) }
scope :pending, -> { where(confirmed_at: nil) }
scope :all_except, -> (user) { where.not(id: user) }
#
# Encrypted database columns
#
has_encrypted :ln_login, :ln_password
# Include default devise modules. Others available are:
@@ -140,6 +158,10 @@ class User < ApplicationRecord
@display_name ||= ldap_entry[:display_name]
end
def avatar
@avatar_base64 ||= LdapManager::FetchAvatar.call(cn: cn, ou: ou)
end
def services_enabled
ldap_entry[:service] || []
end
@@ -168,4 +190,17 @@ class User < ApplicationRecord
return @ldap_service if defined?(@ldap_service)
@ldap_service = LdapService.new
end
def acceptable_avatar
return unless avatar_new.present?
if avatar_new.size > 1.megabyte
errors.add(:avatar, "file size is too large")
end
acceptable_types = ["image/jpeg", "image/png"]
unless acceptable_types.include?(avatar_new.content_type)
errors.add(:avatar, "must be a JPEG or PNG file")
end
end
end
+17
View File
@@ -0,0 +1,17 @@
module LdapManager
class FetchAvatar < LdapManagerService
def initialize(cn:, ou: nil)
@cn = cn
@ou = ou
end
def call
treebase = @ou ? "ou=#{@ou},cn=users,#{suffix}" : ldap_config["base"]
attributes = %w{ jpegPhoto }
filter = Net::LDAP::Filter.eq("cn", @cn)
entry = ldap_client.search(base: treebase, filter: filter, attributes: attributes).first
entry.try(:jpegPhoto) ? entry.jpegPhoto.first : nil
end
end
end
@@ -0,0 +1,27 @@
require "image_processing/vips"
module LdapManager
class UpdateAvatar < LdapManagerService
def initialize(dn, file)
@dn = dn
@img_data = process(file)
end
def call
replace_attribute @dn, :jpegPhoto, @img_data
end
private
def process(file)
processed = ImageProcessing::Vips
.resize_to_fill(512, 512)
.source(file)
.convert("jpeg")
.saver(strip: true)
.call
Base64.strict_encode64 processed.read
end
end
end
+3
View File
@@ -1,2 +1,5 @@
class LdapManagerService < LdapService
def suffix
@suffix ||= ENV["LDAP_SUFFIX"] || "dc=kosmos,dc=org"
end
end
+4
View File
@@ -63,6 +63,10 @@
</section>
<section class="sm:flex-1 sm:pt-0">
<h3>LDAP<h3>
<p>
<img src="data:image/jpeg;base64,<%= @avatar %>" class="h-48 w-48" />
</p>
<!-- <h3>Actions</h3> -->
</section>
</div>
+52 -23
View File
@@ -1,33 +1,62 @@
<section>
<h3>Profile</h3>
<p class="mb-2">
<%= label :user_address, 'User address', class: 'font-bold' %>
</p>
<p data-controller="clipboard" class="flex gap-1 mb-2 sm:w-3/5">
<input type="text" id="user_address" class="grow"
value=<%= @user.address %> disabled="disabled"
data-clipboard-target="source" />
<button id="copy-user-address" class="btn-md btn-icon btn-outline shrink-0"
data-clipboard-target="trigger" data-action="clipboard#copy"
title="Copy to clipboard">
<span class="content-initial">
<%= render partial: "icons/copy", locals: { custom_class: "text-blue-600 h-4 w-4 inline" } %>
</span>
<span class="content-active hidden">
<%= render partial: "icons/check", locals: { custom_class: "text-blue-600 h-4 w-4 inline" } %>
</span>
</button>
</p>
<p class="text-sm text-gray-500">
Your user address for Chat and Lightning Network.
</p>
<div class="mb-6">
<p class="mb-2">
<%= label :user_address, 'User address', class: 'font-bold' %>
</p>
<p data-controller="clipboard" class="flex gap-1 mb-2 sm:w-3/5">
<input type="text" id="user_address" class="grow"
value=<%= @user.address %> disabled="disabled"
data-clipboard-target="source" />
<button id="copy-user-address" class="btn-md btn-icon btn-outline shrink-0"
data-clipboard-target="trigger" data-action="clipboard#copy"
title="Copy to clipboard">
<span class="content-initial">
<%= render partial: "icons/copy", locals: { custom_class: "text-blue-600 h-4 w-4 inline" } %>
</span>
<span class="content-active hidden">
<%= render partial: "icons/check", locals: { custom_class: "text-blue-600 h-4 w-4 inline" } %>
</span>
</button>
</p>
<p class="text-sm text-gray-500">
Your user address for Chat and Lightning Network.
</p>
</div>
<%= form_for(@user, url: setting_path(:profile), html: { :method => :put }) do |f| %>
<%= render FormElements::FieldsetComponent.new(tag: "div", title: "Display name") do %>
<%= f.text_field :display_name, class: "w-full sm:w-3/5 mb-2" %>
<%= f.text_field :display_name, class: "w-full sm:w-3/5" %>
<% if @validation_errors.present? && @validation_errors[:display_name].present? %>
<p class="error-msg"><%= @validation_errors[:display_name].first %></p>
<p class="error-msg mt-2"><%= @validation_errors[:display_name].first %></p>
<% end %>
<% end %>
<label class="block">
<p class="font-bold mb-1">
Avatar
</p>
<p class="text-gray-500">
Default profile picture
</p>
<div class="flex items-center gap-6">
<% if current_user.avatar.present? %>
<p class="flex-none">
<%= image_tag "data:image/jpeg;base64,#{current_user.avatar}", class: "h-24 w-24 rounded-lg" %>
</p>
<% end %>
<div class="grow">
<p class="mb-2">
<%= f.file_field :avatar, class: "" %>
<p class="text-sm text-gray-500">
JPEG or PNG image, not larger than 1 megabyte
</p>
<% if @validation_errors.present? && @validation_errors[:avatar].present? %>
<p class="error-msg mb-2"><%= @validation_errors[:avatar].first %></p>
<% end %>
</div>
</div>
</label>
<p class="mt-8 pt-6 border-t border-gray-200 text-right">
<%= f.submit 'Save', class: "btn-md btn-blue w-full md:w-auto" %>
</p>
+4
View File
@@ -0,0 +1,4 @@
# syntax=docker/dockerfile:1
FROM guildeducation/rails:2.7.2-14.20.0
RUN apt-get update -qq && apt-get install -y --no-install-recommends ldap-utils libvips
+1 -1
View File
@@ -5,7 +5,7 @@ require "rails"
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
# require "active_storage/engine"
require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_mailbox/engine"
+3
View File
@@ -70,4 +70,7 @@ Rails.application.configure do
# Allow requests from any IP
config.web_console.whiny_requests = false
# Store attachments on the local disk (in ./storage)
config.active_storage.service = :local
end
+4
View File
@@ -110,6 +110,10 @@ Rails.application.configure do
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
config.action_mailer.raise_delivery_errors = true
# TODO make configurable
# Store attachments in S3-compatible back-end
config.active_storage.service = :local
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to
# the I18n.default_locale when a translation cannot be found).
config.i18n.fallbacks = true
+3
View File
@@ -51,4 +51,7 @@ Rails.application.configure do
}
config.active_job.queue_adapter = :test
# Store attachments on the local disk (in ./tmp)
config.active_storage.service = :test
end
+4 -4
View File
@@ -1,7 +1,7 @@
test:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>
local:
service: Disk
root: <%= Rails.root.join("storage") %>
test:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>
@@ -0,0 +1,57 @@
# This migration comes from active_storage (originally 20170806125915)
class CreateActiveStorageTables < ActiveRecord::Migration[5.2]
def change
# Use Active Record's configured type for primary and foreign keys
primary_key_type, foreign_key_type = primary_and_foreign_key_types
create_table :active_storage_blobs, id: primary_key_type do |t|
t.string :key, null: false
t.string :filename, null: false
t.string :content_type
t.text :metadata
t.string :service_name, null: false
t.bigint :byte_size, null: false
t.string :checksum
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :key ], unique: true
end
create_table :active_storage_attachments, id: primary_key_type do |t|
t.string :name, null: false
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
t.references :blob, null: false, type: foreign_key_type
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
create_table :active_storage_variant_records, id: primary_key_type do |t|
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
t.string :variation_digest, null: false
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
end
private
def primary_and_foreign_key_types
config = Rails.configuration.generators
setting = config.options[config.orm][:primary_key_type]
primary_key_type = setting || :primary_key
foreign_key_type = setting || :bigint
[primary_key_type, foreign_key_type]
end
end
+31 -1
View File
@@ -10,7 +10,35 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_05_23_120753) do
ActiveRecord::Schema[7.0].define(version: 2023_09_06_073324) do
create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
t.bigint "record_id", null: false
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
end
create_table "active_storage_blobs", force: :cascade do |t|
t.string "key", null: false
t.string "filename", null: false
t.string "content_type"
t.text "metadata"
t.string "service_name", null: false
t.bigint "byte_size", null: false
t.string "checksum"
t.datetime "created_at", null: false
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end
create_table "active_storage_variant_records", force: :cascade do |t|
t.bigint "blob_id", null: false
t.string "variation_digest", null: false
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end
create_table "donations", force: :cascade do |t|
t.integer "user_id"
t.integer "amount_sats"
@@ -94,5 +122,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_05_23_120753) do
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "remote_storage_authorizations", "users"
end
+19
View File
@@ -54,6 +54,11 @@ RSpec.describe 'remoteStorage OAuth Dialog', type: :feature do
context "when signed out" do
let(:user) { create :user }
before do
allow_any_instance_of(User).to receive(:valid_ldap_authentication?)
.with(user.password).and_return(true)
end
it "prefills the username field in the signin form" do
visit new_rs_oauth_path(useraddress: user.address,
redirect_uri: "http://example.com",
@@ -62,5 +67,19 @@ RSpec.describe 'remoteStorage OAuth Dialog', type: :feature do
expect(find("#user_cn").value).to eq(user.cn)
end
it "redirects to the OAuth dialog after sign-in" do
auth_url = new_rs_oauth_url(useraddress: user.address,
redirect_uri: "http://example.com",
client_id: "http://example.com",
scope: "documents,[photos], contacts:r")
visit auth_url
fill_in "User", with: user.cn
fill_in "Password", with: user.password
click_button "Log in"
expect(current_url).to eq(auth_url)
end
end
end
+62 -8
View File
@@ -2,20 +2,19 @@ require 'rails_helper'
RSpec.describe 'Profile settings', type: :feature do
let(:user) { create :user, cn: "mwahlberg" }
let(:avatar_base64) { File.read("#{Rails.root}/spec/fixtures/files/avatar-base64.txt") }
before do
login_as user, :scope => :user
allow(user).to receive(:display_name).and_return("Mark")
allow_any_instance_of(User).to receive(:dn).and_return("cn=mwahlberg,ou=kosmos.org,cn=users,dc=kosmos,dc=org")
allow_any_instance_of(User).to receive(:ldap_entry).and_return({
uid: user.cn, ou: user.ou, display_name: "Mark"
})
allow_any_instance_of(User).to receive(:avatar).and_return(avatar_base64)
end
feature "Update display name" do
before do
allow(user).to receive(:display_name).and_return("Mark")
allow_any_instance_of(User).to receive(:dn).and_return("cn=mwahlberg,ou=kosmos.org,cn=users,dc=kosmos,dc=org")
allow_any_instance_of(User).to receive(:ldap_entry).and_return({
uid: user.cn, ou: user.ou, display_name: "Mark"
})
end
scenario 'fails with validation error' do
visit setting_path(:profile)
fill_in 'Display name', with: "M"
@@ -42,4 +41,59 @@ RSpec.describe 'Profile settings', type: :feature do
end
end
end
feature "Update avatar" do
scenario "fails with validation error for wrong content type" do
visit setting_path(:profile)
attach_file "Avatar", "#{Rails.root}/spec/fixtures/files/bitcoin.pdf"
click_button "Save"
expect(current_url).to eq(setting_url(:profile))
within ".error-msg" do
expect(page).to have_content("must be a JPEG or PNG file")
end
end
scenario "fails with validation error for file size too large" do
visit setting_path(:profile)
attach_file "Avatar", "#{Rails.root}/spec/fixtures/files/fsociety-irc.png"
click_button "Save"
expect(current_url).to eq(setting_url(:profile))
within ".error-msg" do
expect(page).to have_content("file size is too large")
end
end
scenario 'works with valid JPG file' do
file_path = "#{Rails.root}/spec/fixtures/files/taipei.jpg"
expect_any_instance_of(LdapManager::UpdateAvatar).to receive(:replace_attribute)
.with(user.dn, :jpegPhoto, avatar_base64).and_return(true)
visit setting_path(:profile)
attach_file "Avatar", file_path
click_button "Save"
expect(current_url).to eq(setting_url(:profile))
within ".flash-msg" do
expect(page).to have_content("Settings saved")
end
end
scenario 'works with valid PNG file' do
file_path = "#{Rails.root}/spec/fixtures/files/bender.png"
expect(LdapManager::UpdateAvatar).to receive(:call).and_return(true)
visit setting_path(:profile)
attach_file "Avatar", file_path
click_button "Save"
expect(current_url).to eq(setting_url(:profile))
within ".flash-msg" do
expect(page).to have_content("Settings saved")
end
end
end
end
+1
View File
@@ -0,0 +1 @@
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 truncated
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB