lockify 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 76ccb3952ec8db598291189dd3eeb1829d30a70bc898875c80f6fbf0ab92df29
4
+ data.tar.gz: fff2724bd616d40bbc4c80401abd247e552ffd27aa0a6c474781a6182b07ca88
5
+ SHA512:
6
+ metadata.gz: 8d3d08d984ea634f0518a9f79a207a68aa3a66c9d8c0e71f8bdf27e7d56dcaec7011c28a1745335c119821b45d54628331b0810cc6c786f444e3695abf9b3744
7
+ data.tar.gz: 3be69e2b3e0a9ae0e04b9399ac6e31332c1010418e6f04f26c4519ac28224fe3bddce761cdba1799f7a4f2c2c702c3e5c74d8e973890d67ba4ff529c5ae10a29
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2024 Liz - soyprogramador.liz.mx
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.
data/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # Lockify - Drop-in Replacement for Devise
2
+
3
+ Lockify es un reemplazo directo y transparente de Devise para Rails 8, diseñado para funcionar sin modificar código existente.
4
+
5
+ ## Instalación
6
+
7
+ 1. Remover Devise del Gemfile:
8
+ ```ruby
9
+ # gem 'devise' # Comentar o eliminar esta línea
10
+ ```
11
+
12
+ 2. Agregar Lockify al Gemfile:
13
+ ```ruby
14
+ gem 'lockify', path: 'path/to/lockify'
15
+ ```
16
+
17
+ 3. Instalar:
18
+ ```bash
19
+ bundle install
20
+ rails generate lockify:install
21
+ rails db:migrate
22
+ ```
23
+
24
+ ## Compatibilidad con Devise
25
+
26
+ ### Rutas idénticas:
27
+ - `/users/sign_in`
28
+ - `/users/sign_up`
29
+ - `/users/sign_out`
30
+ - `/users/password/new`
31
+ - `/users/password/edit`
32
+ - `/users/confirmation`
33
+
34
+ ### Helpers idénticos:
35
+ - `current_user`
36
+ - `user_signed_in?`
37
+ - `authenticate_user!`
38
+ - `sign_in(user)`
39
+ - `sign_out`
40
+
41
+ ### Controladores compatibles:
42
+ - Variables de instancia: `@user`, `resource`
43
+ - Métodos: `after_sign_in_path_for`, `stored_location_for`
44
+ - Callbacks y filtros funcionan igual
45
+
46
+ ### Vistas compatibles:
47
+ - Misma estructura de directorios
48
+ - Mismos helpers y variables
49
+ - Generador: `rails generate lockify:views`
50
+
51
+ ## Migración desde Devise
52
+
53
+ 1. **Backup de la base de datos**
54
+ 2. **Remover Devise del Gemfile**
55
+ 3. **Instalar Lockify**:
56
+ ```bash
57
+ bundle install
58
+ rails generate lockify:install
59
+ rails db:migrate
60
+ ```
61
+ 4. **Listo** - La aplicación funciona igual
62
+
63
+ ## Campos de base de datos
64
+
65
+ Lockify maneja automáticamente la migración de:
66
+ - `encrypted_password` → `password_digest`
67
+ - Agrega campos faltantes de confirmación, reset, remember, etc.
68
+
69
+ ## Configuración
70
+
71
+ ```ruby
72
+ # config/initializers/lockify.rb
73
+ Lockify.setup do |config|
74
+ config.password_length = 6..128
75
+ config.reset_password_within = 6.hours
76
+ config.maximum_attempts = 20
77
+ end
78
+ ```
79
+
80
+ ## Diferencias técnicas
81
+
82
+ - Usa `has_secure_password` en lugar de `bcrypt` directo
83
+ - Implementación nativa de Rails 8
84
+ - Sin dependencias externas
85
+ - Mejor rendimiento
86
+
87
+ La transición es **100% transparente** para el usuario final y el código de la aplicación.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
@@ -0,0 +1,62 @@
1
+ class Users::ConfirmationsController < ApplicationController
2
+ def new
3
+ self.resource = User.new
4
+ end
5
+
6
+ def create
7
+ self.resource = User.find_by(email: resource_params[:email]&.downcase)
8
+
9
+ if resource
10
+ if resource.confirmed?
11
+ set_flash_message!(:notice, :already_confirmed)
12
+ else
13
+ resource.send_confirmation_instructions
14
+ set_flash_message!(:notice, :send_instructions)
15
+ end
16
+ else
17
+ set_flash_message!(:alert, :not_found)
18
+ end
19
+
20
+ redirect_to new_user_session_path
21
+ end
22
+
23
+ def show
24
+ self.resource = User.find_by(confirmation_token: params[:confirmation_token])
25
+
26
+ if resource&.confirmation_period_expired?
27
+ set_flash_message!(:alert, :expired)
28
+ redirect_to new_user_confirmation_path
29
+ elsif resource
30
+ resource.confirm!
31
+ sign_in(resource)
32
+ set_flash_message!(:notice, :confirmed)
33
+ redirect_to after_confirmation_path_for(:user, resource)
34
+ else
35
+ set_flash_message!(:alert, :invalid_token)
36
+ redirect_to new_user_confirmation_path
37
+ end
38
+ end
39
+
40
+ protected
41
+
42
+ def resource_params
43
+ params.require(:user).permit(:email)
44
+ end
45
+
46
+ def after_confirmation_path_for(resource_name, resource)
47
+ after_sign_in_path_for(resource)
48
+ end
49
+
50
+ def set_flash_message!(type, key, options = {})
51
+ message = find_message(key, options)
52
+ flash[type] = message if message.present?
53
+ end
54
+
55
+ def find_message(key, options = {})
56
+ I18n.t("lockify.confirmations.#{key}", **options)
57
+ end
58
+
59
+ private
60
+
61
+ attr_accessor :resource
62
+ end
@@ -0,0 +1,62 @@
1
+ class Users::PasswordsController < ApplicationController
2
+ def new
3
+ self.resource = User.new
4
+ end
5
+
6
+ def create
7
+ self.resource = User.find_by(email: resource_params[:email]&.downcase)
8
+
9
+ if resource&.confirmed?
10
+ resource.generate_password_reset_token!
11
+ UserMailer.reset_password_instructions(resource).deliver_now
12
+ end
13
+
14
+ set_flash_message!(:notice, :send_instructions)
15
+ redirect_to new_user_session_path
16
+ end
17
+
18
+ def edit
19
+ self.resource = User.find_by(reset_password_token: params[:reset_password_token])
20
+
21
+ if resource.nil? || !resource.reset_password_period_valid?
22
+ set_flash_message!(:alert, :expired)
23
+ redirect_to new_user_password_path
24
+ end
25
+ end
26
+
27
+ def update
28
+ self.resource = User.find_by(reset_password_token: params[:user][:reset_password_token])
29
+
30
+ if resource&.reset_password_period_valid?
31
+ if resource.reset_password!(resource_params[:password], resource_params[:password_confirmation])
32
+ sign_in(resource)
33
+ set_flash_message!(:notice, :updated)
34
+ redirect_to after_sign_in_path_for(resource)
35
+ else
36
+ render :edit, status: :unprocessable_entity
37
+ end
38
+ else
39
+ set_flash_message!(:alert, :expired)
40
+ redirect_to new_user_password_path
41
+ end
42
+ end
43
+
44
+ protected
45
+
46
+ def resource_params
47
+ params.require(:user).permit(:email, :password, :password_confirmation, :reset_password_token)
48
+ end
49
+
50
+ def set_flash_message!(type, key, options = {})
51
+ message = find_message(key, options)
52
+ flash[type] = message if message.present?
53
+ end
54
+
55
+ def find_message(key, options = {})
56
+ I18n.t("lockify.passwords.#{key}", **options)
57
+ end
58
+
59
+ private
60
+
61
+ attr_accessor :resource
62
+ end
@@ -0,0 +1,126 @@
1
+ class Users::RegistrationsController < ApplicationController
2
+ before_action :configure_sign_up_params, only: [:create]
3
+ before_action :configure_account_update_params, only: [:update]
4
+
5
+ def new
6
+ build_resource
7
+ redirect_to after_sign_in_path_for(resource) if user_signed_in?
8
+ end
9
+
10
+ def create
11
+ build_resource(sign_up_params)
12
+
13
+ if resource.save
14
+ if resource.confirmation_required?
15
+ resource.send_confirmation_instructions
16
+ set_flash_message!(:notice, :signed_up_but_unconfirmed)
17
+ redirect_to new_user_session_path
18
+ else
19
+ sign_in(resource)
20
+ set_flash_message!(:notice, :signed_up)
21
+ respond_with resource, location: after_sign_up_path_for(resource)
22
+ end
23
+ else
24
+ clean_up_passwords resource
25
+ render :new, status: :unprocessable_entity
26
+ end
27
+ end
28
+
29
+ def show
30
+ redirect_to edit_user_registration_path
31
+ end
32
+
33
+ def edit
34
+ authenticate_user!
35
+ self.resource = current_user
36
+ end
37
+
38
+ def update
39
+ authenticate_user!
40
+ self.resource = current_user
41
+
42
+ if update_resource(resource, account_update_params)
43
+ bypass_sign_in resource, scope: :user
44
+ set_flash_message!(:notice, :updated)
45
+ redirect_to after_update_path_for(resource)
46
+ else
47
+ clean_up_passwords resource
48
+ render :edit, status: :unprocessable_entity
49
+ end
50
+ end
51
+
52
+ def destroy
53
+ authenticate_user!
54
+ resource = current_user
55
+ resource.destroy
56
+ sign_out(:user)
57
+ set_flash_message!(:notice, :destroyed)
58
+ redirect_to after_sign_out_path_for(:user)
59
+ end
60
+
61
+ protected
62
+
63
+ def build_resource(hash = {})
64
+ self.resource = User.new(hash)
65
+ end
66
+
67
+ def sign_up_params
68
+ params.require(:user).permit(:email, :password, :password_confirmation)
69
+ end
70
+
71
+ def account_update_params
72
+ params.require(:user).permit(:email, :password, :password_confirmation, :current_password)
73
+ end
74
+
75
+ def configure_sign_up_params
76
+ # Override in application if needed
77
+ end
78
+
79
+ def configure_account_update_params
80
+ # Override in application if needed
81
+ end
82
+
83
+ def after_sign_up_path_for(resource)
84
+ after_sign_in_path_for(resource)
85
+ end
86
+
87
+ def after_update_path_for(resource)
88
+ edit_user_registration_path
89
+ end
90
+
91
+ def update_resource(resource, params)
92
+ if params[:password].present?
93
+ resource.update(params)
94
+ else
95
+ params.delete(:password)
96
+ params.delete(:password_confirmation)
97
+ resource.update_without_password(params)
98
+ end
99
+ end
100
+
101
+ def clean_up_passwords(resource)
102
+ resource.password = nil
103
+ resource.password_confirmation = nil
104
+ end
105
+
106
+ def bypass_sign_in(resource, options = {})
107
+ sign_in(resource, options)
108
+ end
109
+
110
+ def respond_with(resource, _opts = {})
111
+ redirect_to after_sign_up_path_for(resource)
112
+ end
113
+
114
+ def set_flash_message!(type, key, options = {})
115
+ message = find_message(key, options)
116
+ flash[type] = message if message.present?
117
+ end
118
+
119
+ def find_message(key, options = {})
120
+ I18n.t("lockify.registrations.#{key}", **options)
121
+ end
122
+
123
+ private
124
+
125
+ attr_accessor :resource
126
+ end
@@ -0,0 +1,68 @@
1
+ class Users::SessionsController < ApplicationController
2
+ before_action :configure_sign_in_params, only: [:create]
3
+ before_action :authenticate_user!, only: [:destroy]
4
+
5
+ def new
6
+ redirect_to after_sign_in_path_for(:user) if user_signed_in?
7
+ end
8
+
9
+ def create
10
+ self.resource = User.find_by(email: sign_in_params[:email]&.downcase)
11
+
12
+ if resource&.authenticate(sign_in_params[:password])
13
+ if resource.valid_for_authentication?
14
+ resource.failed_attempts = 0 if resource.respond_to?(:failed_attempts=)
15
+ resource.save(validate: false) if resource.respond_to?(:failed_attempts)
16
+
17
+ sign_in(resource, remember_me: sign_in_params[:remember_me] == '1')
18
+ respond_with resource, location: after_sign_in_path_for(resource)
19
+ else
20
+ set_flash_message!(:alert, :unconfirmed) unless resource.confirmed?
21
+ set_flash_message!(:alert, :locked) if resource.access_locked?
22
+ redirect_to new_user_session_path
23
+ end
24
+ else
25
+ resource&.increment_failed_attempts
26
+ set_flash_message!(:alert, :invalid)
27
+ render :new, status: :unprocessable_entity
28
+ end
29
+ end
30
+
31
+ def destroy
32
+ signed_out = (user_signed_in?)
33
+ sign_out(:user)
34
+ set_flash_message!(:notice, :signed_out) if signed_out
35
+ respond_to_on_destroy
36
+ end
37
+
38
+ protected
39
+
40
+ def sign_in_params
41
+ params.require(:user).permit(:email, :password, :remember_me)
42
+ end
43
+
44
+ def configure_sign_in_params
45
+ # Override in application if needed
46
+ end
47
+
48
+ def respond_with(resource, _opts = {})
49
+ redirect_to after_sign_in_path_for(resource)
50
+ end
51
+
52
+ def respond_to_on_destroy
53
+ redirect_to after_sign_out_path_for(:user)
54
+ end
55
+
56
+ def set_flash_message!(type, key, options = {})
57
+ message = find_message(key, options)
58
+ flash[type] = message if message.present?
59
+ end
60
+
61
+ def find_message(key, options = {})
62
+ I18n.t("lockify.sessions.#{key}", **options)
63
+ end
64
+
65
+ private
66
+
67
+ attr_accessor :resource
68
+ end
@@ -0,0 +1,21 @@
1
+ class UserMailer < ApplicationMailer
2
+ def confirmation_instructions(user)
3
+ @user = user
4
+ @confirmation_url = user_confirmation_url(confirmation_token: @user.confirmation_token)
5
+
6
+ mail(
7
+ to: @user.email,
8
+ subject: I18n.t('lockify.mailer.confirmation_instructions.subject')
9
+ )
10
+ end
11
+
12
+ def reset_password_instructions(user)
13
+ @user = user
14
+ @reset_password_url = edit_user_password_url(reset_password_token: @user.reset_password_token)
15
+
16
+ mail(
17
+ to: @user.email,
18
+ subject: I18n.t('lockify.mailer.reset_password_instructions.subject')
19
+ )
20
+ end
21
+ end
@@ -0,0 +1,138 @@
1
+ module LockifyUser
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ has_secure_password validations: false
6
+
7
+ validates :email, presence: true, uniqueness: true, format: { with: Lockify.email_regexp }
8
+ validates :password, length: Lockify.password_length, allow_nil: true, confirmation: true
9
+
10
+ before_create :generate_confirmation_token, if: :confirmation_required?
11
+ before_save :downcase_email
12
+ end
13
+
14
+ def confirmed?
15
+ !!confirmed_at
16
+ end
17
+
18
+ def confirmation_required?
19
+ respond_to?(:confirmation_token)
20
+ end
21
+
22
+ def confirmation_period_expired?
23
+ return false unless respond_to?(:confirmation_sent_at)
24
+ return false unless Lockify.confirm_within
25
+ confirmation_sent_at && confirmation_sent_at.utc <= Lockify.confirm_within.ago
26
+ end
27
+
28
+ def confirm!
29
+ if respond_to?(:confirmed_at=)
30
+ self.confirmed_at = Time.current
31
+ self.confirmation_token = nil if respond_to?(:confirmation_token=)
32
+ save(validate: false)
33
+ end
34
+ end
35
+
36
+ def send_confirmation_instructions
37
+ generate_confirmation_token
38
+ save(validate: false)
39
+ UserMailer.confirmation_instructions(self).deliver_now
40
+ end
41
+
42
+ def generate_password_reset_token!
43
+ if respond_to?(:reset_password_token=)
44
+ self.reset_password_token = SecureRandom.urlsafe_base64
45
+ self.reset_password_sent_at = Time.current
46
+ save(validate: false)
47
+ end
48
+ end
49
+
50
+ def reset_password_period_valid?
51
+ return false unless respond_to?(:reset_password_sent_at)
52
+ reset_password_sent_at && reset_password_sent_at.utc >= Lockify.reset_password_within.ago
53
+ end
54
+
55
+ def reset_password!(new_password, new_password_confirmation = nil)
56
+ self.password = new_password
57
+ self.password_confirmation = new_password_confirmation || new_password
58
+
59
+ if respond_to?(:reset_password_token=)
60
+ self.reset_password_token = nil
61
+ self.reset_password_sent_at = nil
62
+ end
63
+
64
+ save
65
+ end
66
+
67
+ def remember_me!
68
+ if respond_to?(:remember_token=)
69
+ self.remember_token = SecureRandom.urlsafe_base64
70
+ self.remember_created_at = Time.current if respond_to?(:remember_created_at=)
71
+ save(validate: false)
72
+ end
73
+ end
74
+
75
+ def forget_me!
76
+ if respond_to?(:remember_token=)
77
+ self.remember_token = nil
78
+ self.remember_created_at = nil if respond_to?(:remember_created_at=)
79
+ save(validate: false)
80
+ end
81
+ end
82
+
83
+ def rememberable_value
84
+ remember_token if respond_to?(:remember_token)
85
+ end
86
+
87
+ def rememberable_options
88
+ {}
89
+ end
90
+
91
+ def lock_access!
92
+ if respond_to?(:locked_at=)
93
+ self.locked_at = Time.current
94
+ save(validate: false)
95
+ end
96
+ end
97
+
98
+ def unlock_access!
99
+ if respond_to?(:locked_at=)
100
+ self.locked_at = nil
101
+ self.failed_attempts = 0 if respond_to?(:failed_attempts=)
102
+ save(validate: false)
103
+ end
104
+ end
105
+
106
+ def access_locked?
107
+ return false unless respond_to?(:locked_at)
108
+ !!locked_at
109
+ end
110
+
111
+ def increment_failed_attempts
112
+ if respond_to?(:failed_attempts)
113
+ self.failed_attempts ||= 0
114
+ self.failed_attempts += 1
115
+ lock_access! if failed_attempts >= Lockify.maximum_attempts
116
+ save(validate: false)
117
+ end
118
+ end
119
+
120
+ def valid_for_authentication?
121
+ return false if respond_to?(:confirmed_at) && !confirmed?
122
+ return false if access_locked?
123
+ true
124
+ end
125
+
126
+ private
127
+
128
+ def generate_confirmation_token
129
+ if respond_to?(:confirmation_token=)
130
+ self.confirmation_token = SecureRandom.urlsafe_base64
131
+ self.confirmation_sent_at = Time.current if respond_to?(:confirmation_sent_at=)
132
+ end
133
+ end
134
+
135
+ def downcase_email
136
+ self.email = email.downcase if email.present?
137
+ end
138
+ end
@@ -0,0 +1,16 @@
1
+ <h2><%= t('.resend_confirmation_instructions') %></h2>
2
+
3
+ <%= form_with(model: resource, as: :user, url: user_confirmation_path, local: true) do |f| %>
4
+ <%= render "lockify/shared/error_messages", resource: resource %>
5
+
6
+ <div class="field">
7
+ <%= f.label :email %>
8
+ <%= f.email_field :email, autofocus: true, autocomplete: "email", value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email) %>
9
+ </div>
10
+
11
+ <div class="actions">
12
+ <%= f.submit t('.resend_confirmation_instructions') %>
13
+ </div>
14
+ <% end %>
15
+
16
+ <%= render "lockify/shared/links" %>
@@ -0,0 +1,25 @@
1
+ <h2><%= t('.change_your_password') %></h2>
2
+
3
+ <%= form_with(model: resource, as: :user, url: user_password_path, local: true) do |f| %>
4
+ <%= render "lockify/shared/error_messages", resource: resource %>
5
+ <%= f.hidden_field :reset_password_token %>
6
+
7
+ <div class="field">
8
+ <%= f.label :password, t('.new_password') %>
9
+ <% if @minimum_password_length %>
10
+ <em>(<%= @minimum_password_length %> characters minimum)</em>
11
+ <% end %>
12
+ <%= f.password_field :password, autofocus: true, autocomplete: "new-password" %>
13
+ </div>
14
+
15
+ <div class="field">
16
+ <%= f.label :password_confirmation, t('.confirm_new_password') %>
17
+ <%= f.password_field :password_confirmation, autocomplete: "new-password" %>
18
+ </div>
19
+
20
+ <div class="actions">
21
+ <%= f.submit t('.change_my_password') %>
22
+ </div>
23
+ <% end %>
24
+
25
+ <%= render "lockify/shared/links" %>
@@ -0,0 +1,16 @@
1
+ <h2><%= t('.forgot_your_password') %></h2>
2
+
3
+ <%= form_with(model: resource, as: :user, url: user_password_path, local: true) do |f| %>
4
+ <%= render "lockify/shared/error_messages", resource: resource %>
5
+
6
+ <div class="field">
7
+ <%= f.label :email %>
8
+ <%= f.email_field :email, autofocus: true, autocomplete: "email" %>
9
+ </div>
10
+
11
+ <div class="actions">
12
+ <%= f.submit t('.send_me_reset_password_instructions') %>
13
+ </div>
14
+ <% end %>
15
+
16
+ <%= render "lockify/shared/links" %>
@@ -0,0 +1,29 @@
1
+ <h2><%= t('.sign_up') %></h2>
2
+
3
+ <%= form_with(model: resource, as: :user, url: user_registration_path, local: true) do |f| %>
4
+ <%= render "lockify/shared/error_messages", resource: resource %>
5
+
6
+ <div class="field">
7
+ <%= f.label :email %>
8
+ <%= f.email_field :email, autofocus: true, autocomplete: "email" %>
9
+ </div>
10
+
11
+ <div class="field">
12
+ <%= f.label :password %>
13
+ <% if @minimum_password_length %>
14
+ <em>(<%= @minimum_password_length %> characters minimum)</em>
15
+ <% end %>
16
+ <%= f.password_field :password, autocomplete: "new-password" %>
17
+ </div>
18
+
19
+ <div class="field">
20
+ <%= f.label :password_confirmation %>
21
+ <%= f.password_field :password_confirmation, autocomplete: "new-password" %>
22
+ </div>
23
+
24
+ <div class="actions">
25
+ <%= f.submit t('.sign_up') %>
26
+ </div>
27
+ <% end %>
28
+
29
+ <%= render "lockify/shared/links" %>