1Maypole::Manual::Flox(3U)ser Contributed Perl DocumentatiMoanypole::Manual::Flox(3)
2
3
4

NAME

6       Maypole::Manual::Flox - Flox: A Free Social Networking Site
7

DESCRIPTION

9       Friendster, Tribe, and now Google's Orkut - it seems like in early
10       2004, everyone wanted to be a social networking site. At the time, I
11       was too busy to be a social networking site, as I was working on my own
12       project at the time - Maypole. However, I realised that if I could
13       implement a social networking system using Maypole, then Maypole could
14       probably do anything.
15
16       I'd already decided there was room for a free, open-source networking
17       site, and then Peter Sergeant came up with the hook - localizing it to
18       universities and societies, and tying in meet-ups with restaurant
19       bookings. I called it Flox, partially because it flocks people together
20       and partially because it's localised for my home town of Oxford and its
21       university student population.
22
23       Flox is still in, uh, flux, but it does the essentials. We're going to
24       see how it was put together, and how the techniques shown in the
25       Request Cookbook can help to create a sophisticated web application. Of
26       course, I didn't have this manual available at the time, so it took a
27       bit longer than it should have done...
28
29   Mapping the concepts
30       Any Maypole application should start with two things: a database
31       schema, and some idea of what the pages involved are going to look
32       like.  Usually, these pages will be displaying or editing some element
33       of the database, so these two concepts should come hand in hand.
34
35       When I started looking at social networking sites, I began by
36       identifying the concepts which were going to make up the tables of the
37       application. At its most basic, a site like Orkut or Flox has two
38       distinct concepts: a user, and a connection between two users.
39       Additionally, there's the idea of an invitation to a new user, which
40       can be extended, accepted, declined or ignored. These three will make
41       up the key tables; there are an extra two tables in Flox, but they're
42       essentially enumerations that are a bit easier to edit: each user has
43       an affiliation to a particular college or department, and a status in
44       the university. (Undergraduate, graduate, and so on.)
45
46       For this first run-through, we're going to ignore the ideas of
47       societies and communities, and end up with a schema like so:
48
49           CREATE TABLE user (
50               id int not null auto_increment primary key,
51               first_name varchar(50),
52               last_name varchar(50),
53               email varchar(255),
54               profile text,
55               password varchar(255),
56               affiliation int,
57               unistatus int,
58               status ENUM("real", "invitee"),
59               photo blob,
60               photo_type varchar(30)
61           );
62
63           CREATE TABLE connection (
64               id int not null auto_increment primary key,
65               from_user int,
66               to_user int,
67               status ENUM("offered", "confirmed")
68           );
69
70           CREATE TABLE invitation (
71               id char(32) not null primary key,
72               issuer int,
73               recipient int,
74               expires date
75           );
76
77       Plus the definition of our two auxiliary tables:
78
79           CREATE TABLE affiliation (
80               id int not null auto_increment primary key,
81               name varchar(255)
82           );
83
84           CREATE TABLE unistatus (
85               id int not null auto_increment primary key,
86               name varchar(255)
87           );
88
89       Notice that, for simplicity, invitations and friendship connections are
90       quite similar: they are extended from one user to another. This means
91       that people who haven't accepted an invite yet still have a place in
92       the user table, with a different "status". Similarly, a connection
93       between users can be offered, and when it is accepted, its status is
94       changed to "confirmed" and a reciprocal relationship put in place.
95
96       We also have some idea, based on what we want to happen, of what pages
97       and actions we're going to define. Leaving the user aside for the
98       moment, we want an action which extends an invitation from the current
99       user to a new user. We want a page the new user can go to in order to
100       accept that invitation. Similarly, we want an action which offers a
101       friendship connection to an existing user, and a page the user can go
102       to to accept or reject it. This gives us five pages so far:
103
104           invitation/issue
105           invitation/accept
106
107           user/befriend
108           connection/accept
109           connection/reject
110
111       Notice that the "befriend" action is performed on a user, not a
112       connection. This is distinct from "invitation/issue" because when
113       befriending, we have a real user on the system that we want to do
114       something to. This makes sense if you think of it in terms of object
115       oriented programming - we could say
116
117           Flox::Connection->create(to => $user)
118
119       but it's clearer to say
120
121           $user->befriend
122
123       Similarly, we could say
124
125           Flox::User->create({ ... })->issue_invitation_to
126
127       but it's clearer to say
128
129           Flox::Invitation->issue( to => Flox::User->create({ ... }) )
130
131       because it more accurately reflects the principal subject and object of
132       these actions.
133
134       Returning to look at the user class, we want to be able to view a
135       user's profile, edit one's own profile, set up the profile for the
136       first time, upload pictures and display pictures. We also need to
137       handle the concepts of logging in and logging out.
138
139       As usual, though, we'll start with a handler class which sets up the
140       database:
141
142           package Flox;
143           use Maypole::Application;
144           Flox->setup("dbi:mysql:flox");
145           Flox->config->display_tables([qw[user invitation connection]]);
146           1;
147
148       Very simple, as these things are meant to be. Now let's build on it.
149
150   Users and Authentication
151       The concept of a current user is absolutely critical in a site like
152       Flox; it represents "me", the viewer of the page, as the site explores
153       the connections in my world. We've described the authentication hacks
154       briefly in the Request Cookbook, but now it's time to go into a little
155       more detail about how user handling is done.
156
157       We also want to be able to refer to the current user from the
158       templates, so we use the overridable "additional_data" method in the
159       driver class to give us a "my" template variable:
160
161           sub additional_data {
162               my $r = shift; $r->template_args->{my} = $r->user;
163           }
164
165       I've called it "my" rather than "me" because we it lets us check "[%
166       my.name %]", and so on.
167
168   Viewing a user
169       The first page that a user will see after logging in will be their own
170       profile, so in order to speed development, we'll start by getting a
171       "user/view" page up.
172
173       The only difference from a programming point of view between this
174       action and the default "view" action is that, if no user ID is given,
175       then we want to view "me", the current user. Remembering that the
176       default view action does nothing, our "Flox::User::view" action only
177       needs to do nothing plus ensure it has a user in the "objects" slot,
178       putting "$r->{user}" in there if not:
179
180           sub view :Exported {
181               my ($class, $r) = @_;
182               $r->objects([ $r->user ]) unless @{ $r->objects || [] };
183           }
184
185       Maypole, unfortunately, is very good at making programming boring. The
186       downside of having to write very little code at all is that we now have
187       to spend most of our time writing nice HTML for the templates.
188
189   Pictures of Users
190       The next stage is viewing the user's photo. Assuming we've got the
191       photo stored in the database already (which is a reasonable assumption
192       for the moment since we don't have a way to upload a photo quite yet)
193       then we can use a variation of the "Displaying pictures" hack from the
194       Request Cookbook:
195
196           sub view_picture :Exported {
197               my ($self, $r) = @_;
198               my $user = $r->objects->[0] || $r->user;
199               if ($r->content_type($user->photo_type)) {
200                  $r->output($user->photo);
201               } else {
202                  # Read no-photo photo
203                  $r->content_type("image/png");
204                  $r->output(slurp_file("images/no-photo.png"));
205               }
206           }
207
208       We begin by getting a user object, just like in the "view" action:
209       either the user whose ID was passed in on the URL, or the current user.
210       Then we check if a "photo_type" has been set in this user's record. If
211       so, then we'll use that as the content type for this request, and the
212       data in the "photo" attribute as the data to send out. The trick here
213       is that setting "$r->{output}" overrides the whole view class
214       processing and allows us to write the content out directly.
215
216       In our template, we can now say
217
218           <IMG SRC="[%base%]/user/view_picture/[% user.id %]">
219
220       and the appropriate user's mugshot will appear.
221
222       However, if we're throwing big chunks of data around like "photo", it's
223       now worth optimizing the "User" class to ensure that only pertitent
224       data is fetched by default, and "photo" and friends are only fetched on
225       demand. The "lazy population" section of Class::DBI's man page explains
226       how to group the columns by usage so that we can optimize fetches:
227
228           Flox::User->columns(Primary   => qw/id/);
229           Flox::User->columns(Essential => qw/status/);
230           Flox::User->columns(Helpful   => qw/ first_name last_name email password/)
231           Flox::User->columns(Display   => qw/ profile affiliation unistatus /);
232           Flox::User->columns(Photo     => qw/ photo photo_type /);
233
234       This means that the status and ID columns will always be retrieved when
235       we deal with a user; next, any one of the name, email or password
236       columns will cause that group of data to be retrieved; if we go on to
237       display more information about a user, we also load up the profile,
238       affiliation and university status; finally, if we're throwing around
239       photos, then we load in the photo type and photo data.
240
241       These groupings are somewhat arbitrary, and there needs to be a lot of
242       profiling to determine the most efficient groupings of columns to load,
243       but they demonstrate one principle about working in Maypole: this is
244       the first time in dealing with Maypole that we've had to explicitly
245       list the columns of a table, but Maypole has so far Just Worked.
246       There's a difference, though, between Maypole just working and Maypole
247       working well, and if you want to optimize your application, then you
248       need to start putting in the code to do that. The beauty of Maypole is
249       that you can do as much or as little of such optimization as you want
250       or need.
251
252       So now we can view users and their photos. It's time to allow the users
253       to edit their profiles and upload a new photo.
254
255   Editing user profiles
256       I introduced Flox to a bunch of friends and told them to be as ruthless
257       as possible in finding bugs and trying to break it. And break it they
258       did; within an hour the screens were thoroughly messed up as users had
259       nasty HTML tags in their profiles, names, email addresses and so on.
260       This spawned another hack in the request cookbook: "Limiting data for
261       display". I changed the untaint columns to use "html" untainting, and
262       all was better:
263
264           Flox::User->untaint_columns(
265               html      => [qw/first_name last_name profile/],
266               printable => [qw/password/],
267               integer   => [qw/affiliation unistatus /],
268               email     => [qw/email/]
269           );
270
271       The next stage was the ability to upload a photo. We unleash the
272       "Uploading files" recipe, with an additional check to make sure the
273       photo is of a sensible size:
274
275           use constant MAX_IMAGE_SIZE => 512 * 1024;
276           sub do_upload :Exported {
277               my ($class, $r) = @_;
278               my $user = $r->user;
279               my $upload = $r->ar->upload("picture");
280               if ($upload) {
281                   my $ct = $upload->info("Content-type");
282                   return $r->error("Unknown image file type $ct")
283                       if $ct !~ m{image/(jpeg|gif|png)};
284                   return $r->error("File too big! Maximum size is ".MAX_IMAGE_SIZE)
285                       if $upload->size > MAX_IMAGE_SIZE;
286
287                   my $fh = $upload->fh;
288                   my $image = do { local $/; <$fh> };
289
290                   use Image::Size;
291                   my ($x, $y) = imgsize(\$image);
292                   return $r->error("Image too big! ($x, $y) Maximum size is 350x350")
293                       if $y > 350 or $x > 350;
294                   $r->user->photo_type($ct);
295                   $r->user->photo($image);
296               }
297
298               $r->objects([ $user ]);
299               $r->template("view");
300           }
301
302       Now we've gone as far as we want to go about user editing at the
303       moment.  Let's have a look at the real meat of a social networking
304       site: getting other people involved, and registering connections
305       between users.
306
307   Invitations
308       We need to do two things to make invitations work: first provide a way
309       to issue an invitation, and then provide a way to accept it. Since what
310       we're doing in issuing an invitation is essentially creating a new one,
311       we'll use our usual practice of having a page to display the form to
312       offer an invitation, and then use a "do_edit" method to actually do the
313       work. So our "issue" method is just an empty action:
314
315           sub issue :Exported {}
316
317       and the template proceeds as normal:
318
319           [% PROCESS header %]
320           <h2> Invite a friend </h2>
321
322           <FORM ACTION="[%base%]/invitation/do_edit/" METHOD="post">
323           <TABLE>
324
325       Now we use the "Catching errors in a form" recipe from the Request
326       Cookbook and write our form template:
327
328           <TR><TD>
329           First name: <INPUT TYPE="text" NAME="forename"
330           VALUE="[%request.params.forename%]">
331           </TD>
332           <TD>
333           Last name: <INPUT TYPE="text" NAME="surname"
334           VALUE="[%request.params.surname%]">
335           </TD></TR>
336           [% IF errors.forename OR errors.surname %]
337               <TR>
338               <TD><SPAN class="error">[% errors.forename %]</SPAN> </TD>
339               <TD><SPAN class="error">[% errors.surname %]</SPAN> </TD>
340               </TR>
341           [% END %]
342           <TR>
343           ...
344
345       Now we need to work on the "do_edit" action. This has to validate the
346       form parameters, create the invited user, create the row in the
347       "invitation" table, and send an email to the new user asking them to
348       join.
349
350       We'd normally use "create_from_cgi" to do the first two stages, but
351       this time we handle the untainting manually, because there are a
352       surprising number of things we need to check before we actually do the
353       create. So here's the untainting of the parameters:
354
355           sub do_edit :Exported {
356               my ($self, $r) = @_;
357               my $h = CGI::Untaint->new(%{$r->params});
358               my (%errors, %ex);
359               for (qw( email forename surname )) {
360                   $ex{$_} = $h->extract(
361                           "-as_".($_ eq "email" ? "email" : "printable") => $_
362                   ) or $errors{$_} = $h->error;
363               }
364
365       Next, we do the usual dance of throwing the user back at the form in
366       case of errors:
367
368               if (keys %errors) {
369                   $r->template_args->{message} =
370                       "There was something wrong with that...";
371                   $r->template_args->{errors} = \%errors;
372                   $r->template("issue");
373                   return;
374               }
375
376       We've introduced a new template variable here, "message", which we'll
377       use to display any important messages to the user.
378
379       The first check we need to do is whether or not we already have a user
380       with that email address. If we have, and they're a real user, then we
381       abort the invite progress and instead redirect them to viewing that
382       user's profile.
383
384               my ($user) = Flox::User->search({ email => $ex{email} });
385               if ($user) {
386                   if ($user->status eq "real") {
387                       $r->template_args->{message} =
388                           "That user already seems to exist on Flox. ".
389                           "Is this the one you meant?";
390
391                       $self->redirect_to_user($r, $user);
392                   }
393
394       Where "redirect_to_user" looks like this:
395
396           sub redirect_to_user {
397               my ($self, $r, $user) = @_;
398               $r->objects([ $user ]);
399               $r->template("view");
400               $r->model_class("Flox::User"); # Naughty.
401           }
402
403       This is, as the comment quite rightly points out, naughty. We're
404       currently doing a "/invitation/do_edit/" and we want to turn this into
405       a "/user/view/xxx", changing the table, template and arguments all at
406       once.  To do this, we have to change the Maypole request object's idea
407       of the model class, since this determines where to look for the
408       template: if we didn't, we'd end up with "invitation/view" instead of
409       "user/view".
410
411       Ideally, we'd do this with a Apache redirect, but we want to get that
412       "message" in there as well, so this will have to do. This isn't good
413       practice; we put it into a subroutine so that we can fix it up if we
414       find a better way to do it.
415
416       Anyway back in the "do_edit" action, this is what we should do if a
417       user already exists on the system and has accepted an invite already.
418       What if we're trying to invite a user but someone else has invited them
419       first and they haven't replied yet?
420
421                    } else {
422                       # Put it back to the form
423                       $r->template_args->{message} =
424                           "That user has already been invited; " .
425                           "please wait for them to accept";
426                       $r->template("issue");
427                    }
428                    return;
429               }
430
431       Race conditions suck.
432
433       Okay. Now we know that the user doesn't exist, and so can create the
434       new one:
435
436               my $new_user = Flox::User->create({
437                   email      => $ex{email},
438                   first_name => $ex{forename},
439                   last_name  => $ex{surname},
440                   status     => "invitee"
441               });
442
443       We want to give the invitee a URL that they can go to in order to
444       accept the invite. Now we don't just want the IDs of our invites to be
445       sequential, since someone could get one invite, and then guess the rest
446       of the invite codes. We provide a relatively secure MD5 hash as the
447       invite ID:
448
449               my $random = md5_hex(time.(0+{}).$$.rand);
450
451       For additional security, we're going to have the URL in the form
452       "/invitation/accept/id/from_id/to_id", encoding the user ids of the two
453       users. Now we can send email to the invitee to ask them to visit that
454       URL:
455
456               my $newid = $new_user->id;
457               my $myid  = $r->user->id;
458               _send_mail(to   => $ex{email},
459                          url  => "$random/$myid/$newid",
460                          user => $r->user);
461
462       I'm not going to show the "_send_mail" routine, since it's boring.  We
463       haven't actually created the "Invitation" object yet, so let's do that
464       now.
465
466               Flox::Invitation->create({
467                   id        => $random,
468                   issuer    => $r->user,
469                   recipient => $new_user,
470                   expires   => Time::Piece->new(time + LIFETIME)->datetime
471               });
472
473       You can also imagine a daily cron job that cleans up the "Invitation"
474       table looking for invitations that ever got replied to within their
475       lifetime:
476
477          ($_->expires > localtime && $_->delete)
478              for Flox::Invitation->retrieve_all;
479
480       Notice that we don't check whether the ID is already used. We could,
481       but, you know, if MD5 sums start colliding, we have much bigger
482       problems on our hands.
483
484       Anyway, now we've got the invitation created, we can go back to whence
485       we came: viewing the original user:
486
487               $self->redirect_to_user($r, $r->user);
488
489       Now our invitee has an email, and goes click on the URL. What happens?
490
491       XXX
492
493   Friendship Connections
494       XXX
495
496   Links
497       The source for Flox is available at
498       <http://cvs.simon-cozens.org/viewcvs.cgi/flox>.
499
500       Contents, Next The Maypole iBuySpy Portal, Previous Maypole Request
501       Hacking Cookbook
502
503
504
505perl v5.36.0                      2022-07-22          Maypole::Manual::Flox(3)
Impressum