diff --git a/rails/app/assets/stylesheets/hub.scss b/rails/app/assets/stylesheets/hub.scss index d465db9e8..53d4a3cac 100644 --- a/rails/app/assets/stylesheets/hub.scss +++ b/rails/app/assets/stylesheets/hub.scss @@ -4,6 +4,12 @@ h1 { color: #bbb; font-size: 65%; } + + .gravatar { + border-radius: 5px; + margin-right: 0.2em; + margin-top: -6px; + } } .nav-link { @@ -39,13 +45,19 @@ h1 { font-size: 90%; } + svg.bi { + color: #aaa; + } + .gravatar { + margin-top: -2px; border-radius: 2px; } } .site-tags { margin-left: 0.4em; + margin-top: 2px; font-size: 90%; } } diff --git a/rails/app/controllers/application_controller.rb b/rails/app/controllers/application_controller.rb index 63b313835..1a5e198fd 100644 --- a/rails/app/controllers/application_controller.rb +++ b/rails/app/controllers/application_controller.rb @@ -18,8 +18,8 @@ def redirect_www_requests end def permit_devise_params - devise_parameter_sanitizer.permit(:sign_up, keys: [:name]) - devise_parameter_sanitizer.permit(:account_update, keys: [:name, :use_gravatar]) + devise_parameter_sanitizer.permit(:sign_up, keys: [:name, :username]) + devise_parameter_sanitizer.permit(:account_update, keys: [:name, :username, :use_gravatar]) end def require_admin_user! diff --git a/rails/app/controllers/hub_controller.rb b/rails/app/controllers/hub_controller.rb index 06737feba..c21338034 100644 --- a/rails/app/controllers/hub_controller.rb +++ b/rails/app/controllers/hub_controller.rb @@ -1,62 +1,28 @@ class HubController < ApplicationController - before_action :prepare_sorting, - :prepare_searching, - :prepare_tags, - :prepare_sites_and_render def index + render_hub end def tag + @tag = params[:tag] + render_hub end - # (Unused currently) - def twplugins - end - - # (Unused currently) - def twdocs - end - - private - - def prepare_sites_and_render - @sites = Site.searchable.order(@sort_by[:field]) - @sites = @sites.tagged_with(@tag) if @tag.present? - @sites = @sites.search_for(@search) if @search.present? - @sites = @sites.paginate(page: params[:page]) - - render action: :index - end - - def prepare_tags - @hub_tags = Settings.hub_tags - @tag_tabs = Site.tags_for_searchable_sites.limit(4).pluck(:name) - - # (Unused currently since there are no hub_tags) - if @hub_tags.keys.include?(action_name) - tag_info = @hub_tags[action_name] - @tag = tag_info[:tag] - @title = tag_info[:title] - @tag_description = tag_info[:description] - - elsif params[:tag] - @tag = params[:tag] - # Beware that @tag is html unsafe - @tag_tabs = @tag_tabs.prepend(@tag).uniq - @tag_description = "Searchable sites tagged with '#{@tag}'." - + def user + if params[:username].present? && user = User.find_by_username(params[:username]) + @user = user + render_hub else - @title = "Tiddlyhost Hub" - @tag_description = "If you mark your site as 'Searchable' it will be listed here." + # TODO: 404 page here maybe + redirect_to '/hub' end end - def prepare_searching - @search = params[:search] - end + private - def prepare_sorting + def render_hub + # Prepare sort options @sort_options = { 'views' => { name: 'view count', @@ -69,6 +35,31 @@ def prepare_sorting } @sort_by = @sort_options[params[:sort]] || @sort_options['views'] + + # Prepare search + @search = params[:search] + + # Prepare tag tabs + @tag_tabs = Site.tags_for_searchable_sites.limit(4).pluck(:name) + @tag_tabs = @tag_tabs.prepend(@tag).uniq if @tag.present? + + # Apply sorting + @sites = Site.searchable.order(@sort_by[:field]) + + # Apply tag filtering + @sites = @sites.tagged_with(@tag) if @tag.present? + + # Apply user filtering + @sites = @sites.where(user_id: @user.id) if @user.present? + + # Apply search filtering + @sites = @sites.search_for(@search) if @search.present? + + # Paginate + @sites = @sites.paginate(page: params[:page]) + + # Render + render action: :index end end diff --git a/rails/app/helpers/application_helper.rb b/rails/app/helpers/application_helper.rb index efdd55f02..75179135b 100644 --- a/rails/app/helpers/application_helper.rb +++ b/rails/app/helpers/application_helper.rb @@ -23,7 +23,7 @@ def bi_icon(icon, opts={}) class: ["bi"].append(opts.delete(:class)).compact, height: "1.2em", width: "1.4em", - style: "margin-top:-3px;margin-right:4px;#{opts.delete(:style)}") + style: "margin-top:-3px;margin-right:3px;#{opts.delete(:style)}") content_tag(:svg, opts) do content_tag(:use, nil, "xlink:href" => diff --git a/rails/app/helpers/sites_helper.rb b/rails/app/helpers/sites_helper.rb index 4c86bc246..2d0ed6277 100644 --- a/rails/app/helpers/sites_helper.rb +++ b/rails/app/helpers/sites_helper.rb @@ -36,11 +36,7 @@ def site_tags(site) end def tag_url(tag_name) - if hub_action = Settings.hub_tags_lookup[tag_name] - "/hub/#{hub_action}" - else - "/hub/tag/#{tag_name}" - end + "/hub/tag/#{tag_name}" end def clickable_site_tags(site) diff --git a/rails/app/javascript/packs/application.js b/rails/app/javascript/packs/application.js index 50890a81c..ca84d8fb9 100644 --- a/rails/app/javascript/packs/application.js +++ b/rails/app/javascript/packs/application.js @@ -20,21 +20,35 @@ $(document).ready(function(){ event.preventDefault(); }) - // Make it so the user can't easily type invalid names. - // We'll validate the name on the server as well, see app/models/site. - $('#site_name').on('keyup', function(){ - $(this).val( - $(this).val(). - // Remove anything that's not a letter, numeral or dash - replace(/[^0-9a-z-]/, ''). - // Replace consecutive dashes with a single dash - replace(/[-]{2,}/, '-'). - // Remove leading dashes - // (Don't remove trailing dashes since it makes it hard to type - // names with dashes. The server will invalidate them anyway.) - replace(/^-/, '') + var limitChars = function() { + var inputField = $(this); + + if (inputField.attr('id') == 'site_name') { + // Allow lowercase letters, numerals and dashes + var notAllowed = /[^0-9a-z-]/; + } + else { // id == 'user_username" + // Same thing but also allow uppercase + var notAllowed = /[^0-9a-zA-Z-]/; + } + + var currentVal = inputField.val(); + inputField.val(currentVal. + // Remove any disallowed chars + replace(notAllowed, ''). + // Replace consecutive dashes with a single dash + replace(/[-]{2,}/, '-'). + // Remove leading dashes + // (Don't remove trailing dashes since it makes it hard to type + // names with dashes. The server will invalidate them anyway.) + replace(/^-/, '') ); - }); + }; + + // Make it so the user can't easily type invalid site names or usernames. + // We'll validate server-side as well, see User and Site model validations. + $('input#site_name').on('keyup', limitChars); + $('input#user_username').on('keyup', limitChars); // If site is set to private, automatically make it unsearchable // If site is set to searchable, automatically make it public diff --git a/rails/app/models/user.rb b/rails/app/models/user.rb index 2870befd1..bcc8524ae 100644 --- a/rails/app/models/user.rb +++ b/rails/app/models/user.rb @@ -19,6 +19,28 @@ class User < ApplicationRecord belongs_to :plan validates_presence_of :name + validates :username, + uniqueness: { + case_sensitive: false, + }, + length: { + minimum: 3, + maximum: 30, + allow_blank: true, + }, + format: { + # Must be upper- or lowercase letters, numerals, and dashes + # Must not have more than one consecutive dash + # Must not start or end with a dash + # (See also app/javascript/packs/application.js) + without: / [^A-Za-z0-9-] | -- | ^- | -$ /x, + message: "'%{value}' is not allowed. Please choose a different username.", + } + + def username_or_name + username.presence || name + end + def has_plan?(plan_name) plan.name == plan_name.to_s end diff --git a/rails/app/views/devise/registrations/edit.html.haml b/rails/app/views/devise/registrations/edit.html.haml index fc2fc7163..1eac900f6 100644 --- a/rails/app/views/devise/registrations/edit.html.haml +++ b/rails/app/views/devise/registrations/edit.html.haml @@ -5,19 +5,26 @@ .form-group =render 'devise/shared/error_messages', resource: resource - .form-group - =f.label :name - =f.text_field :name, autofocus: true, autofill: 'off', autocomplete: 'off', class: 'form-control col-sm-6' - .form-group =f.label :email - =f.email_field :email, autocomplete: 'email', class: 'form-control col-sm-6' + =f.email_field :email, autocomplete: true, autocomplete: 'email', class: 'form-control col-sm-6' + %small.form-text.text-muted Your email address -if devise_mapping.confirmable? && resource.pending_reconfirmation? %small.form-text.text-muted Currently waiting confirmation for: =resource.unconfirmed_email + .form-group + =f.label :name + =f.text_field :name, autofocus: true, autofill: 'off', autocomplete: 'off', class: 'form-control col-sm-6' + %small.form-text.text-muted Your name + + .form-group + = f.label :username + = f.text_field :username, autofocus: true, autofill: 'off', autocomplete: 'off', class: 'form-control col-sm-6' + %small.form-text.text-muted Unique username (optional) + .form-group =gravatar_image(resource) %div.form-group diff --git a/rails/app/views/devise/registrations/new.html.haml b/rails/app/views/devise/registrations/new.html.haml index 413b95f29..85b1d7b99 100644 --- a/rails/app/views/devise/registrations/new.html.haml +++ b/rails/app/views/devise/registrations/new.html.haml @@ -5,15 +5,20 @@ .form-group =render 'devise/shared/error_messages', resource: resource + .form-group + = f.label :email + = f.email_field :email, autofocus: true, autocomplete: 'email', class: 'form-control col-sm-6' + %small.form-text.text-muted Enter your email address + .form-group = f.label :name - = f.text_field :name, autofocus: true, autofill: 'off', autocomplete: 'off', class: 'form-control col-sm-6' + = f.text_field :name, autofill: 'off', autocomplete: 'off', class: 'form-control col-sm-6' %small.form-text.text-muted Please enter your name .form-group - = f.label :email - = f.email_field :email, autocomplete: 'email', class: 'form-control col-sm-6' - %small.form-text.text-muted Enter your email address + = f.label :username + = f.text_field :username, autofocus: true, autofill: 'off', autocomplete: 'off', class: 'form-control col-sm-6' + %small.form-text.text-muted Optional. Choose a unique username between 3 and 30 characters long. .form-group = f.label :password diff --git a/rails/app/views/hub/index.html.haml b/rails/app/views/hub/index.html.haml index 53955ca42..c6d2703db 100644 --- a/rails/app/views/hub/index.html.haml +++ b/rails/app/views/hub/index.html.haml @@ -1,14 +1,28 @@ -%h1= @title || bi_icon(:tag) + @tag +%h1 + - if @tag.present? + = bi_icon('tag') + @tag + - elsif @user.present? + - if @user.use_gravatar? + = gravatar_image(@user, size: 35) + @user.name + - else + = bi_icon('person-fill') + @user.name + - else + Tiddlyhost Hub %p.text-muted{style: 'font-size: 95%'} - = @tag_description + - if @tag.present? + Sites tagged with + %b= @tag + '.' + - elsif @user.present? + Sites created by Tiddlyhost user + %b= @user.username + '.' + - else + If you mark your site as 'Searchable' it will be listed here. %div{style: "margin-bottom: 2em;"} %ul.nav.nav-tabs = hub_link_to('All sites', '/hub') - - @hub_tags.each do |k, tag_info| - = hub_link_to(tag_info[:title], "/hub/#{k}") - @tag_tabs.each do |tag| = hub_tag_link_to(tag) @@ -35,15 +49,21 @@ .hub - @sites.each do |s| - .site + .site{id: s.name} .name = site_long_link s .description = span_with_title_truncated(s.description) .owner - %span Owner: - = gravatar_image(s.user, size: 16) if s.user.use_gravatar? - = s.user.name + - if s.user.use_gravatar? + = gravatar_image(s.user, size: 14) if s.user.use_gravatar? + - else + = bi_icon('person-fill', style: 'margin-right:-3px;') + - if s.user.username.present? + %span{title: s.user.name} + = link_to s.user.username, "/hub/user/#{s.user.username}" + - else + = s.user.name %span{style: 'margin-left: 0.5em;'} Views: = s.view_count - unless s.tag_list.empty? diff --git a/rails/app/views/layouts/_header.html.erb b/rails/app/views/layouts/_header.html.erb index 30e702cbe..784dff86c 100644 --- a/rails/app/views/layouts/_header.html.erb +++ b/rails/app/views/layouts/_header.html.erb @@ -22,7 +22,7 @@ <%= nav_link_to "Sites", admin_sites_path %> <%= nav_link_to "Users", admin_users_path %> <% end %> - <%= nav_link_to current_user.name, edit_user_registration_path, icon: 'person-fill' %> + <%= nav_link_to current_user.username_or_name, edit_user_registration_path, icon: 'person-fill' %> <%= nav_link_to "Log out", destroy_user_session_path, method: :delete, icon: 'box-arrow-left' %> <% else %> diff --git a/rails/config/initializers/app_version.rb b/rails/config/initializers/app_version.rb index c4910e386..4201bdba7 100644 --- a/rails/config/initializers/app_version.rb +++ b/rails/config/initializers/app_version.rb @@ -1 +1 @@ -App::VERSION = '0.0.3' +App::VERSION = '0.0.4' diff --git a/rails/config/initializers/devise.rb b/rails/config/initializers/devise.rb index 2fa6f57d8..ed2d384c3 100644 --- a/rails/config/initializers/devise.rb +++ b/rails/config/initializers/devise.rb @@ -63,7 +63,7 @@ # Configure which authentication keys should have whitespace stripped. # These keys will have whitespace before and after removed upon creating or # modifying a user and when used to authenticate or find a user. Default is :email. - config.strip_whitespace_keys = [:email, :name] + config.strip_whitespace_keys = [:email, :name, :username] # Tell if authentication through request.params is enabled. True by default. # It can be set to an array that will enable params authentication only for the diff --git a/rails/config/routes.rb b/rails/config/routes.rb index 42d3ef31f..a26b4a38e 100644 --- a/rails/config/routes.rb +++ b/rails/config/routes.rb @@ -28,10 +28,8 @@ get 'admin/sites' get 'hub', to: 'hub#index' - Settings.hub_tags.keys.each do |k| - get "hub/#{k}", controller: :hub - end get "hub/tag/:tag", controller: :hub, action: :tag + get "hub/user/:username", controller: :hub, action: :user resources :sites end diff --git a/rails/config/settings.yml b/rails/config/settings.yml index c183c396c..7c083a6a2 100644 --- a/rails/config/settings.yml +++ b/rails/config/settings.yml @@ -6,17 +6,6 @@ defaults: default_plan_name: basic minimum_password_length: 8 - # Let's turn these off for now and see what tags appear organically - #hub_tags: - # twplugins: - # :title: TiddlyWiki Plugin Hub - # :tag: TiddlyWikiPlugin - # :description: Sites containing useful plugins created by the TiddlyWiki community. - # twdocs: - # :title: TiddlyWiki Docs Hub - # :tag: TiddlyWikiDoc - # :description: Sites container documentation, tutorials, and other informational resources related to TiddlyWiki. - #banner_message: # :icon: exclamation-triangle # :html: | diff --git a/rails/db/migrate/20210222220346_add_username_to_users.rb b/rails/db/migrate/20210222220346_add_username_to_users.rb new file mode 100644 index 000000000..1f58f1792 --- /dev/null +++ b/rails/db/migrate/20210222220346_add_username_to_users.rb @@ -0,0 +1,5 @@ +class AddUsernameToUsers < ActiveRecord::Migration[6.1] + def change + add_column :users, :username, :string, limit: 30 + end +end diff --git a/rails/db/schema.rb b/rails/db/schema.rb index 5a49c6003..9c4a760c1 100644 --- a/rails/db/schema.rb +++ b/rails/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_02_22_211144) do +ActiveRecord::Schema.define(version: 2021_02_22_220346) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -111,6 +111,7 @@ t.datetime "updated_at", precision: 6, null: false t.bigint "plan_id", default: 1 t.boolean "use_gravatar", default: false + t.string "username", limit: 30 t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true t.index ["plan_id"], name: "index_users_on_plan_id" diff --git a/rails/lib/settings.rb b/rails/lib/settings.rb index 2c76be8f2..69f843e3f 100644 --- a/rails/lib/settings.rb +++ b/rails/lib/settings.rb @@ -38,12 +38,4 @@ def self.nil_blobs_ok? Rails.env.development? end - def self.hub_tags - SETTINGS['hub_tags'] || {} - end - - def self.hub_tags_lookup - @@_hub_tags_lookup ||= Hash[Settings.hub_tags.map{ |k,t| [t[:tag], k] }] - end - end diff --git a/rails/test/controllers/hub_controller_test.rb b/rails/test/controllers/hub_controller_test.rb index 0caaccd67..cef970abb 100644 --- a/rails/test/controllers/hub_controller_test.rb +++ b/rails/test/controllers/hub_controller_test.rb @@ -2,14 +2,58 @@ class HubControllerTest < ActionDispatch::IntegrationTest - test "should get index" do + setup do + @user = users(:bobby) + @site = sites(:mysite) + end + + test "hub index" do + get '/hub' + assert_response :success + assert_site_visible + + # Make it not searchable + @site.update(is_searchable: false) get '/hub' assert_response :success + assert_site_not_visible end - test "tag urls" do + test "hub tag urls" do get '/hub/tag/bananas' assert_response :success + assert_site_not_visible + + @site.tag_list.add('bananas') + @site.save! + get '/hub/tag/bananas' + assert_response :success + assert_site_visible + end + + test "hub user urls" do + get '/hub/user/bobby' + assert_response :success + assert_site_visible + + # Make it not searchable + @site.update(is_searchable: false) + get '/hub/user/bobby' + assert_response :success + assert_site_not_visible + end + + test "a non existent user" do + get '/hub/user/doesntexist' + assert_redirected_to '/hub' + end + + def assert_site_visible(site=@site) + assert_select(".hub .site##{site.name}", count: 1) + end + + def assert_site_not_visible(site=@site) + assert_select(".hub .site##{site.name}", count: 0) end end diff --git a/rails/test/fixtures/sites.yml b/rails/test/fixtures/sites.yml index 18d5d765d..344608484 100644 --- a/rails/test/fixtures/sites.yml +++ b/rails/test/fixtures/sites.yml @@ -4,6 +4,8 @@ mysite: name: mysite user_id: 1 view_count: 1 + is_private: 0 + is_searchable: 1 accessed_at: <%= 1.hours.ago %> updated_at: <%= 2.hours.ago %> created_at: <%= 3.hours.ago %> diff --git a/rails/test/fixtures/users.yml b/rails/test/fixtures/users.yml index 366641291..82bd70ffd 100644 --- a/rails/test/fixtures/users.yml +++ b/rails/test/fixtures/users.yml @@ -3,6 +3,7 @@ bobby: id: 1 plan_id: 1 email: bobby@tables.com + username: bobby encrypted_password: "$2a$12$7Vo3LPYma6We0nrvr.8iiewwdiir2g1BncZreaif3juRKLUZWOPMi" reset_password_token: reset_password_sent_at: diff --git a/rails/test/integration/user_signup_test.rb b/rails/test/integration/user_signup_test.rb index b340ef2e1..003a65eb3 100644 --- a/rails/test/integration/user_signup_test.rb +++ b/rails/test/integration/user_signup_test.rb @@ -3,8 +3,8 @@ class UserSignupTest < CapybaraIntegrationTest test "user signup" do - name, email, weak_password, strong_password = - 'Testy McTest', 'tmctest@mail.com', 'trstno1', 'trUst|no1' + name, email, username, weak_password, strong_password = + 'Testy McTest', 'tmctest@mail.com', 'tmt', 'trstno1', 'trUst|no1' # Visit home page and click sign up link visit '/' @@ -13,6 +13,7 @@ class UserSignupTest < CapybaraIntegrationTest # Fill in the sign up form fields fill_in 'user[name]', with: name fill_in 'user[email]', with: email + fill_in 'user[username]', with: username fill_in 'user[password]', with: weak_password fill_in 'user[password_confirmation]', with: weak_password diff --git a/rails/test/models/user_test.rb b/rails/test/models/user_test.rb index 5ad8c8d52..2efc2be05 100644 --- a/rails/test/models/user_test.rb +++ b/rails/test/models/user_test.rb @@ -9,4 +9,65 @@ class UserTest < ActiveSupport::TestCase test "user plans" do assert_equal 'basic', @user.plan.name end + + test "username uniqueness" do + User.create!( + email: 'bob@gmail.com', name: 'Another Bob', username: 'Bob', password: 'Abcd1234') + + [ + 'bob', + 'Bob', + 'BOB', + + ].each do |disallowed_username| + @user.username = disallowed_username + refute @user.valid? + assert_match /has already been taken/, @user.errors.full_messages.first + end + end + + test "username validation" do + [ + # No leading or trailing dashes + '-bob-', + '-bob', + 'bob-', + # No spaces or other chars + 'bob bobby', + 'bob$', + 'bob_bobby', + # No Double dashes + 'bo--b', + # Too short + 'bb', + # Too long + 'b' * 31, + + ].each do |disallowed_username| + @user.username = disallowed_username + refute @user.valid? + assert_match /is not allowed|is too short|is too long/, @user.errors.full_messages.first + end + + [ + 'bob', + 'bob-tables', + 'Bob ', + 'BOB', + # Max length + 'b' * 30, + # Blank is allowed + '', + ' ', + + ].each do |allowed_username| + @user.username = allowed_username + assert @user.valid? + assert @user.save + # Case is preserved, trailing space is stripped + assert_equal allowed_username.strip, @user.reload.username + end + + end + end