Concierge
view release on metacpan or search on metacpan
lib/Concierge.pm view on Meta::CPAN
package Concierge v0.13.0;
use v5.36;
our $VERSION = 'v0.13.0';
# ABSTRACT: Extensible service layer that manages the relationship between an application and its users
use Carp qw<carp croak>;
use JSON::PP qw< encode_json decode_json >;
use File::Spec;
use Params::Filter qw< make_filter >;
# === COMPONENT MODULES ===
use Concierge::Desk::User;
use Concierge::Desk::Component;
use Concierge::Auth;
use Concierge::Sessions;
use Concierge::Users;
# === PARAMETER FILTERS ===
# Shared filters for secure data segregation
# Auth filter - ONLY credentials (user_id + password)
our $auth_data_filter = make_filter(
[qw(user_id password)], # required credentials
[], # accepted - nothing else
[], # excluded - not needed
);
# User data filter - everything EXCEPT credentials
# Handles both minimal input (user_id, moniker) and
# rich input (user_id, moniker, email, phone, bio, etc.)
our $user_data_filter = make_filter(
[qw(user_id moniker)], # required minimum
['*'], # accept ALL other fields, except:
[qw(password confirm_password)], # excluded - security boundary
);
# Session data filter - for populating session with initial data
# Accepts user_id (required for new_session) plus any session fields
# Excludes credentials (never stored in session data)
our $session_data_filter = make_filter(
[qw(user_id)], # required for new_session
['*'], # accept all other fields, except:
[qw(password confirm_password)], # excluded - security boundary
);
# User update filter - for updating existing user records
# No required fields (user_id passed separately as parameter)
# Excludes user_id (identity field), password (use reset_password instead)
our $user_update_filter = make_filter(
[], # no required fields
['*'], # accept all fields, except:
[qw(user_id password confirm_password)], # excluded - never in updates
);
sub new_concierge {
my ($class) = @_;
bless {}, $class;
}
# =============================================================================
# DESK MANAGEMENT - Opening existing desks
# =============================================================================
sub open_desk ($class, $desk_location) {
croak "Desk not found: *$desk_location*" unless -d $desk_location;
my $concierge = Concierge->new_concierge(); # minimal object
$concierge->{desk_location} = $desk_location;
# Instantiate the concierge from config stored in Concierge's config file
my $concierge_conf_file = File::Spec->catfile($desk_location, 'concierge.conf');
# Read entire file (pretty JSON spans multiple lines)
my ($fh,$json,$concierge_config);
{
local $/;
open $fh, "<", $concierge_conf_file
and
$json = <$fh>
and
close $fh
or return { success => 0, message => "Error closing session file: $!" };
}
unless (defined $json) {
return { success => 0, message => "Config file is empty" };
}
eval {
$concierge_config = decode_json($json);
};
if ($@) {
return { success => 0, message => "Invalid JSON in config file: $@" };
}
# Instantiate sessions manager from $concierge_config
$concierge->{sessions} = Concierge::Sessions->new(
storage_dir => $concierge_config->{sessions_dir} || $concierge_config->{storage_dir},
backend_class => $concierge_config->{sessions_backend},
);
# Load user_keys mapping from file (or initialize empty for new desk)
lib/Concierge.pm view on Meta::CPAN
# Step 2: Retrieve session (validates it still exists and hasn't expired)
my $session_result = $self->sessions->get_session($session_id);
unless ($session_result->{success}) {
# Session gone or expired -- clean up stale mapping
delete $self->{user_keys}{$user_key};
$self->save_user_keys();
return { success => 0, message => 'Session expired' };
}
my $session = $session_result->{session};
# Step 3: Determine user type -- logged-in or guest
my $user_result = $self->users->get_user($user_id);
if ($user_result->{success}) {
# Logged-in user: rebuild with user data and backend closures
my ($get, $update, $verify_password, $reset_password) = $self->_make_user_closures($user_id);
my $logout = sub { $self->logout_user($session_id) };
my $session_valid = sub { $self->sessions->get_session($session_id) };
my $user = Concierge::Desk::User->enable_user($user_id, {
session => $session,
user_data => $user_result->{user},
user_key => $user_key,
_get_user_data => $get,
_update_user_data => $update,
_verify_password => $verify_password,
_reset_password => $reset_password,
_logout => $logout,
_session_valid => $session_valid,
});
return {
success => 1,
message => 'User restored',
user => $user,
};
}
else {
# Guest: session only, no user data -- but a session still means
# it can log itself out.
my $logout = sub { $self->logout_user($session_id) };
my $session_valid = sub { $self->sessions->get_session($session_id) };
my $user = Concierge::Desk::User->enable_user($user_id, {
session => $session,
user_key => $user_key,
_logout => $logout,
_session_valid => $session_valid,
});
return {
success => 1,
message => 'Guest restored',
user => $user,
is_guest => 1,
};
}
}
# Login user: authenticate, create session, assign user_key and store external_key mapping
sub login_user ($self, $credentials, $session_opts={}) {
# Step 0: Get credentials
my $auth_data = $auth_data_filter->($credentials);
return { success => 0, message => 'Missing user_id or password' }
unless $auth_data;
my $user_id = $auth_data->{user_id};
my $password = $auth_data->{password};
# Step 1: Get user from database
my $user_result = $self->users->get_user($user_id);
return { success => 0, message => 'User not found' }
unless $user_result->{success};
# Step 2: Authenticate with ID & password
my $authed = $self->auth->authenticate($user_id, $password);
my ($auth_ok, $auth_msg) = ($authed->{success}, $authed->{message});
return { success => 0, message => $auth_msg || 'Authentication failed' }
unless $auth_ok;
# Step 3: Create session
my $session_result = $self->sessions->new_session(
user_id => $user_id,
%{ $session_opts || {} },
);
return { success => 0, message => $session_result->{message} || 'Failed to create session' }
unless $session_result->{success};
my $session = $session_result->{session};
my $session_id = $session->session_id();
# Create user object for logged-in user
my ($get, $update, $verify_password, $reset_password) = $self->_make_user_closures($user_id);
my $logout = sub { $self->logout_user($session_id) };
my $session_valid = sub { $self->sessions->get_session($session_id) };
my $user = Concierge::Desk::User->enable_user($user_id, {
session => $session,
user_data => $user_result->{user},
_get_user_data => $get,
_update_user_data => $update,
_verify_password => $verify_password,
_reset_password => $reset_password,
_logout => $logout,
_session_valid => $session_valid,
});
# Store user_key mapping
$self->{user_keys}{$user->user_key()} = {
user_id => $user_id,
session_id => $session_id,
};
$self->save_user_keys();
return {
success => 1,
message => 'Login successful',
user => $user,
};
}
# Verify password: check if password is correct for user
sub verify_password ($self, $user_id, $password) {
# Verifies if provided password is correct for the user
# Usually not needed - if user has valid session/user_key, they're already authenticated
# Use cases: sensitive operations requiring re-authentication, admin verification, etc.
# Most password resets don't need this - session authentication is sufficient
# Minimal validation - application controls when this is called
return { success => 0, message => 'user_id is required' }
unless defined $user_id && length($user_id);
return { success => 0, message => 'password is required' }
unless defined $password && length($password);
# Check password via Auth component
my $authed = $self->auth->authenticate($user_id, $password);
my ($pwd_ok, $pwd_msg) = ($authed->{success}, $authed->{message});
return {
success => $pwd_ok ? 1 : 0,
message => $pwd_msg || ($pwd_ok ? 'Password verified' : 'Invalid password'),
user_id => $user_id,
};
}
# Reset password: set user's new password
sub reset_password ($self, $user_id, $new_password) {
# Changes user password using Auth component
# Application is responsible for verifying user identity and old password if needed
return { success => 0, message => 'user_id is required' }
unless defined $user_id && length($user_id);
return { success => 0, message => 'new_password is required' }
unless defined $new_password && length($new_password);
# Reset existing password using Auth's change_credentials
# Pass through Auth's messages (Auth provides detailed error messages)
my $changed = $self->auth->change_credentials($user_id, $new_password);
my ($reset_ok, $reset_msg) = ($changed->{success}, $changed->{message});
return {
success => $reset_ok ? 1 : 0,
message => $reset_msg || ($reset_ok ? 'Password reset successful' : 'Password reset failed'),
user_id => $user_id,
};
}
# Logout user: delete session and remove from concierge mapping
sub logout_user ($self, $session_id) {
# Logs out user by deleting their session
# Removes from concierge user_keys mapping
return { success => 0, message => 'No session provided for logout' }
unless defined $session_id && length($session_id);
# Step 1: Verify session exists
my $session_check = $self->sessions->get_session($session_id);
unless ($session_check->{success}) {
return { success => 0, message => 'Session not found for logout' };
}
# Step 2: Find user_key for this session (to remove from mapping)
my $user_key_to_delete;
my $user_id;
for my $key (keys %{$self->{user_keys}}) {
if ($self->{user_keys}{$key}{session_id} eq $session_id) {
$user_key_to_delete = $key;
$user_id = $self->{user_keys}{$key}{user_id};
last;
}
}
# Step 3: Delete session from Sessions component
my $delete_result = $self->sessions->delete_session($session_id);
unless ($delete_result->{success}) {
return { success => 0, message => $delete_result->{message} || 'Failed to delete session' };
}
# Step 4: Remove from concierge user_keys mapping
if ($user_key_to_delete) {
delete $self->{user_keys}{$user_key_to_delete};
my $save_result = $self->save_user_keys();
unless ($save_result->{success}) {
return { success => 0, message => 'Session deleted but failed to update mapping' };
}
}
return {
success => 1,
message => 'Logout successful',
session_id => $session_id,
($user_id ? (user_id => $user_id) : ()),
};
}
1;
lib/Concierge.pm view on Meta::CPAN
takes one of two forms:
=over 4
=item *
For the identity core, Concierge directly provides the capability:
C<login_user()> authenticates via Auth, retrieves a record from Users, and
creates a session via Sessions in one coordinated call.
=item *
For an added component, Concierge's involvement can end at handoff: the
component satisfies the minimal contract in L<Concierge::Desk::Component> so
it can be loaded and reached through the concierge, but Concierge does not
inherit from it or in any way intervene in its operations - that is for the
application as it uses the component.
=back
=head2 Operational Resources
An application's "operational resources" are the services and data stores
that support its main purpose without being that purpose -- authentication,
sessions, and user records are the built-in examples, but the term applies
equally to anything an application uses to support its main functionality.
Concierge is deliberately agnostic about what an operational resource actually
is: other than the identity core components, an added component needs only
to satisfy the contract in L<Concierge::Desk::Component> to be set up,
deployed, and provided to the application the same way.
=head1 DESCRIPTION
Concierge manages the relationship between an application and its users:
identity, sessions, access, and the information an application keeps
about who is using it. It does this through an extensible service layer
-- see L</CONCEPTS> for the terms used throughout this documentation.
Applications primarily interact with Concierge and the L<Concierge::Desk::User>
objects it returns; most operations never require touching a component
module directly. When a needed operation isn't exposed as a Concierge-level
method, any component -- identity-core or added -- remains reachable
through its own accessor (see L</Component Accessors> and L</EXTENSIBILITY>).
=head2 What the Suite Provides
Concierge handles orchestration -- coordinating components and returning
consistent structured results. As L</Orchestration> above describes,
this takes one of two co-equal forms: direct, built-in coordination for
the identity core, or handoff to whatever additional components a desk
is configured with.
The identity core -- Auth, Sessions, and Users -- ships with every
installation and is described in detail below. An added component is
just as much a capability of the suite once it's attached to a desk; see
L</Additional Components> for how those are configured, loaded, and
reached.
B<Authentication> (L<Concierge::Auth>): Argon2id password hashing and
verification; no plaintext credentials are ever written to disk. Also
provides random token, UUID, word-passphrase, and hex-ID generators. The
component is substitutable: any replacement implementing the same method
contract (C<authenticate>, C<enroll>, C<change_credentials>, etc. -- see
L<Concierge::Auth::Base>) can replace it for LDAP, OAuth, or any other
scheme.
B<Sessions> (L<Concierge::Sessions>): Full session lifecycle -- creation,
retrieval, expiry, and cleanup -- with SQLite, file, or in-memory storage.
Sessions carry arbitrary key/value data. A single-session-per-user policy
is enforced: creating a new session automatically removes any prior session
for that user. Expired sessions are cleaned up each time a desk is opened.
B<User Records> (L<Concierge::Users>): User data store with a configurable
field schema. Standard fields (C<moniker>, C<email>, C<phone>,
C<access_level>, C<user_status>, C<term_ends>, and others) are built in and
can be selectively overridden. Applications add their own fields via
C<app_fields> at setup time. Provides built-in SQLite, YAML, and CSV/TSV
backends, with filtering and listing operations.
For the full API of any component, see its own documentation.
=head2 Desks
A I<desk> is a storage directory containing the configuration for the
application's concierge. Normally the data and any config files for the
identity core components are kept there, and any additional components
may be configured for that as well. At the same time, paths may be provided
to other locations to accommodate considerations such as security and
interfacing with existing systems.
Use L<Concierge::Desk::Setup> to create a desk, then C<open_desk()> to load
it at runtime.
=head2 User Participation Levels
Concierge provides three graduated levels of user participation, each
returning a L<Concierge::Desk::User> object:
=over 4
=item B<Visitor> -- C<admit_visitor()>
Assigned a unique identifier only. No session, no stored data. Suitable for
anonymous tracking (e.g., cookies).
=item B<Guest> -- C<checkin_guest()>
Assigned an identifier and a session. Can store temporary data (e.g., a
shopping cart). No authentication or persistent user record.
=item B<Logged-in user> -- C<login_user()>
Authenticated with credentials. Has a session, persistent user data, and
full access to the User object's data methods.
=back
A guest can be converted to a logged-in user with C<login_guest()>,
transferring any session data accumulated during the guest session.
=head2 User Keys
Each active user (guest or logged-in) is tracked by a I<user_key> -- a
random token stored in the concierge's C<user_keys> mapping alongside the
user's C<user_id> and C<session_id>. This mapping is persisted to
C<user_keys.json> in the desk directory and synchronized against active
sessions when the desk is opened.
=head2 Return Values
All methods return a hashref with at least C<success> (0 or 1) and
C<message>:
# Success
{ success => 1, message => '...', ... }
# Failure
{ success => 0, message => 'error description' }
Success responses include additional fields relevant to the operation:
=over 4
=item *
User lifecycle methods (C<login_user()>, C<restore_user()>, C<checkin_guest()>,
C<admit_visitor()>, C<login_guest()>) return C<user>, a L<Concierge::Desk::User>
object. Guest and visitor results also set C<is_guest> or C<is_visitor> to 1.
=item *
C<open_desk()> returns C<concierge>, the ready-to-use Concierge object.
=item *
User management methods return C<user_id>. C<remove_user()> also returns
C<deleted_from> (arrayref of component names) and, if any deletion failed,
C<warnings> (arrayref).
=item *
C<verify_user()> returns C<verified> (0 or 1), C<exists_in_auth>, and
C<exists_in_users>.
=item *
C<list_users()> returns C<user_ids> (arrayref) and C<count>. With
C<< include_data => 1 >>, also returns C<users> (hashref keyed by user_id).
=back
See the individual method descriptions below for the complete field list.
lib/Concierge.pm view on Meta::CPAN
Returns C<< { success => 1, concierge => $obj } >> on success.
=head3 new_concierge
my $concierge = Concierge->new_concierge();
Low-level constructor: returns a bare, uninitialized C<Concierge> object with
no components attached. Used internally by C<open_desk()>. Application code
should use C<open_desk()> (or C<Concierge::Desk::Setup::build_desk()> for
first-time setup) rather than calling this directly.
=head3 save_user_keys
my $result = $concierge->save_user_keys();
Writes the in-memory C<user_keys> mapping (user_key => session/user_id
lookup data, used by C<restore_user()>) to persistent storage as JSON.
Called automatically after any operation that changes the mapping
(login, logout, guest promotion, user removal, etc.); applications do
not normally need to call it directly.
Returns C<< { success => 1 } >> on success, or
C<< { success => 0, message => '...' } >> if the file cannot be written.
=head2 Component Accessors
=head3 auth, sessions, users
my $auth_obj = $concierge->auth;
my $sessions_obj = $concierge->sessions;
my $users_obj = $concierge->users;
Read-only accessors returning the live, already-instantiated component
object ( L<Concierge::Auth>, L<Concierge::Sessions>, or L<Concierge::Users>,
or a substitute -- see L</EXTENSIBILITY> ) for direct use when an operation
isn't exposed as a Concierge-level convenience method. This is the same
"bare accessor" direct component access described in L</Component Substitution>.
=head2 User Lifecycle
=head3 admit_visitor
my $result = $concierge->admit_visitor();
my $user = $result->{user}; # Concierge::Desk::User (visitor)
Creates a visitor with a generated identifier. No session is created
and no data is stored.
=head3 checkin_guest
my $result = $concierge->checkin_guest(\%session_opts);
my $user = $result->{user}; # Concierge::Desk::User (guest)
Creates a guest with a generated identifier and a session. The optional
C<%session_opts> hashref may include C<timeout> (in seconds; defaults to
1800).
=head3 login_user
my $result = $concierge->login_user(\%credentials, \%session_opts);
my $user = $result->{user}; # Concierge::Desk::User (logged-in)
Authenticates C<user_id> and C<password> from C<%credentials>, retrieves
the user's data record, creates a session, and returns a fully-equipped
User object. If the user already has an active session, the previous
session is replaced.
=head3 restore_user
my $result = $concierge->restore_user($user_key);
my $user = $result->{user}; # Concierge::Desk::User (guest or logged-in)
Reconstructs a User object from a C<user_key> (typically stored in a cookie
or URL token). Looks up the key in the concierge mapping, validates the
session, and determines whether the user is a guest or logged-in user.
Logged-in users are restored with their full user data snapshot and backend
closures. Guests are restored with their session only.
If the session has expired, the stale mapping entry is cleaned up and the
method returns failure. The application can then redirect to login or create
a new guest as appropriate.
Returns C<< { success => 1, user => $user } >> on success. Guest restores
also include C<< is_guest => 1 >>.
=head3 login_guest
my $result = $concierge->login_guest(\%credentials, $guest_user_key);
my $user = $result->{user}; # Concierge::Desk::User (logged-in)
Converts a guest to a logged-in user. If C<%credentials>' C<user_id> does
not already belong to a known user, registers a new account first (same
requirements as C<add_user()>); if it does belong to a known user (e.g. a
returning customer who browsed as a guest before logging in to pay),
registration is skipped and the existing account is used instead.
Authenticates with C<%credentials>, transfers any data from the guest's
session to the new session, then deletes the guest session and removes
the guest's user_key mapping.
Returns failure if C<user_id> exists in only one of the Auth/Users
components (an inconsistent state) rather than attempting either path.
=head3 logout_user
my $result = $concierge->logout_user($session_id);
Deletes the session and removes the user_key mapping entry.
Also available directly on the returned user object as C<< $user->logout >>
-- see L<Concierge::Desk::User>. Unlike this method, the user-object form
needs no C<$session_id> argument (it's bound at construction) and works for
both guest and logged-in users, since logout only needs a session, not an
Auth-backed identity.
=head2 Admin Operations
=head3 add_user
my $result = $concierge->add_user(\%user_input);
Registers a new user. C<%user_input> must include C<user_id>, C<moniker>,
and C<password>. Any additional fields (C<email>, C<phone>, application-
defined fields, etc.) are stored in the Users component. The password is
stored separately in the Auth component and never reaches the user data
store.
If password validation fails, the Users record is rolled back.
=head3 remove_user
my $result = $concierge->remove_user($user_id);
Removes the user from all components: Users, Auth, Sessions, and the
user_keys mapping. Attempts all deletions; the response includes
C<deleted_from> (arrayref) and C<warnings> (arrayref, if any component
deletion failed).
=head3 verify_user
my $result = $concierge->verify_user($user_id);
Checks whether C<$user_id> exists in both Auth and Users components.
Returns C<< verified => 1 >> only if present in both. Includes
C<exists_in_auth> and C<exists_in_users> flags, and a C<warning> if
the user exists in one component but not the other.
=head3 list_users
# IDs only
my $result = $concierge->list_users($filter, \%options);
my @ids = @{ $result->{user_ids} };
# With full data
my $result = $concierge->list_users('', { include_data => 1 });
my %users = %{ $result->{users} };
lib/Concierge.pm view on Meta::CPAN
=head3 update_user_data
my $result = $concierge->update_user_data($user_id, \%updates);
Updates the user's record in the Users component. The C<user_id> and
C<password> fields are filtered out and cannot be changed through this
method.
=head2 Password Operations
Initial password registration is handled by C<add_user()>, which sets the
password atomically with user creation. The methods here operate on passwords
for existing users.
=head3 verify_password
my $result = $concierge->verify_password($user_id, $password);
Checks whether C<$password> is correct for C<$user_id>. Returns
C<< success => 1 >> if the password matches.
Also available directly on a logged-in user object as
C<< $user->verify_password($password) >> -- see L<Concierge::Desk::User>.
The user-object form needs no C<$user_id> argument (it's bound at
construction) and collapses the hashref result to a plain C<1>/C<0>/
C<undef> scalar; C<undef> means the method isn't applicable to that user
(guests and visitors have no Auth-backed identity to check against).
=head3 reset_password
my $result = $concierge->reset_password($user_id, $new_password);
Sets a new password for an existing user. The application is responsible
for verifying the user's identity before calling this method.
Also available directly on a logged-in user object as
C<< $user->reset_password($new_password) >> -- see
L<Concierge::Desk::User>. Same argument-binding and scalar-collapsing
behavior as C<verify_password> above, except success collapses to C<1>
and any non-success (including inapplicability to guests/visitors)
collapses to C<undef>.
=head1 PARAMETER FILTERS
Concierge uses L<Params::Filter> to enforce data segregation at method
boundaries:
=over 4
=item C<$auth_data_filter> -- extracts only C<user_id> and C<password>
=item C<$user_data_filter> -- extracts everything except C<password>
=item C<$session_data_filter> -- extracts C<user_id> plus non-credential fields
=item C<$user_update_filter> -- excludes C<user_id> and C<password> from updates
=back
These ensure that credentials never leak into user data stores and that
identity fields cannot be changed via update operations.
=head1 EXTENSIBILITY
See L</CONCEPTS> for the ideas behind extensibility, service-layer
guarantees, and orchestration that this section implements.
=head2 Component Substitution
Each identity core component can be replaced with a drop-in alternative as
long as the replacement implements the methods Concierge calls on it.
B<Auth> -- Concierge calls:
$auth->authenticate($user_id, $credential)
$auth->is_id_known($user_id)
$auth->enroll($user_id, $credential, \%opts?)
$auth->change_credentials($user_id, $new_credential)
$auth->revoke($user_id)
A substitute backend must implement this contract -- see
L<Concierge::Auth::Base>, which also provides working default
L<Concierge::Auth::Generators> methods (used for visitor/guest
identifiers, independent of authentication) to any backend that
inherits from it; a substitute only needs to override these if it
wants different generation logic.
B<Sessions> -- Concierge calls:
=over 4
=item C<< Concierge::Sessions->new(%args) >> -- constructor; accepts C<storage_dir> and C<backend_class>
=item C<< $sessions->new_session(%args) >> -- returns C<< { success => 1, session => $obj } >>
=item C<< $sessions->get_session($session_id) >> -- returns C<< { success => 1, session => $obj } >>
=item C<< $sessions->delete_session($session_id) >> -- returns C<< { success => 1|0, ... } >>
=item C<< $sessions->cleanup_sessions() >> -- returns C<< { success => 1, deleted_count => N, active => [...] } >>
=back
B<Users> -- Concierge calls:
=over 4
=item C<< Concierge::Users->new($config_file) >> -- constructor
=item C<< $users->register_user(\%data) >> -- returns C<< { success => 1|0, message => '...' } >>
=item C<< $users->get_user($user_id) >> -- returns C<< { success => 1, user => \%data } >>
=item C<< $users->update_user($user_id, \%updates) >> -- returns C<< { success => 1|0, ... } >>
=item C<< $users->delete_user($user_id) >> -- returns C<< { success => 1|0, ... } >>
=item C<< $users->list_users($filter) >> -- returns C<< { success => 1, user_ids => [...] } >>
=back
=head2 Additional Components
Beyond the identity core, a desk can declare additional components --
Organizations, Assets, Catalog, etc. -- via a C<components> block passed
to C<< Concierge::Desk::Setup::build_desk() >>:
my $result = Concierge::Desk::Setup::build_desk({
base_dir => './desk',
auth => { backend => 'pwd' },
sessions => { backend => 'database' },
users => { backend => 'database' },
components => {
organizations => {
class => 'Concierge::Organizations',
optional => 0,
dir => 'organizations', # optional; resolved like
# other components' dirs
( run in 4.540 seconds using v1.01-cache-2.11-cpan-0fb53d1c279 )