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