]> git.decadent.org.uk Git - maypole.git/blob - lib/Maypole/Manual/Flox.pod
fixed bug 22899 + broken link in manual contents, removed some warnings, fixed DFV...
[maypole.git] / lib / Maypole / Manual / Flox.pod
1 =head1 NAME
2
3 Maypole::Manual::Flox - Flox: A Free Social Networking Site
4
5 =head1 DESCRIPTION
6
7 Friendster, Tribe, and now Google's Orkut - it seems like in early 2004,
8 everyone wanted to be a social networking site. At the time, I was too
9 busy to be a social networking site, as I was working on my own project
10 at the time - Maypole. However, I realised that if I could implement a
11 social networking system using Maypole, then Maypole could probably do
12 anything.
13
14 I'd already decided there was room for a free, open-source networking
15 site, and then Peter Sergeant came up with the hook - localizing it to
16 universities and societies, and tying in meet-ups with restaurant
17 bookings. I called it Flox, partially because it flocks people together
18 and partially because it's localised for my home town of Oxford and its
19 university student population.
20
21 Flox is still in, uh, flux, but it does the essentials. We're going to
22 see how it was put together, and how the techniques shown in the
23 L<Request Cookbook|Maypole::Manual::Request> can help to
24 create a sophisticated web
25 application. Of course, I didn't have this manual available at the time,
26 so it took a bit longer than it should have done...
27
28 =head2 Mapping the concepts
29
30 Any Maypole application should start with two things: a database schema,
31 and some idea of what the pages involved are going to look like.
32 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 can
40 be extended, accepted, declined or ignored. These three will make up the
41 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 an
43 affiliation to a particular college or department, and a status in the
44 university. (Undergraduate, graduate, and so on.)
45
46 For this first run-through, we're going to ignore the ideas of societies
47 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 the
92 user table, with a different C<status>. Similarly, a connection between
93 users can be offered, and when it is accepted, its status is changed to
94 "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 to
102 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 C<befriend> action is performed on a user, not a
112 connection. This is distinct from C<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 user's
135 profile, edit one's own profile, set up the profile for the first
136 time, upload pictures and display pictures. We also need to handle the
137 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 =head2 Users and Authentication
151
152 The concept of a current user is absolutely critical in a site like
153 Flox; it represents "me", the viewer of the page, as the site explores
154 the connections in my world. We've described the authentication hacks
155 briefly in the L<Request Cookbook|Maypole::Manual::Request>,
156 but now it's time to go into a little more detail about how user
157 handling is done.
158
159 We also want to be able to refer to the current user from the templates,
160 so we use the overridable C<additional_data> method in the driver class
161 to give us a C<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 C<my> rather than C<me> because we it lets us check 
168 C<[% my.name %]>, and so on.
169
170 =head2 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 C<user/view> page up.
175
176 The only difference from a programming point of view between this action
177 and the default C<view> action is that, if no user ID is given, then we
178 want to view "me", the current user. Remembering that the default view
179 action does nothing, our C<Flox::User::view> action only needs to do
180 nothing plus ensure it has a user in the C<objects> slot, putting
181 C<$r-E<gt>{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 =head2 Pictures of Users
193
194 The next stage is viewing the user's photo. Assuming we've got the photo
195 stored in the database already (which is a reasonable assumption for the
196 moment since we don't have a way to upload a photo quite yet) then we
197 can use a variation of the "Displaying pictures" hack from the 
198 L<Request Cookbook|Maypole::Manual::Request>:
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 C<view> action: either
213 the user whose ID was passed in on the URL, or the current user. Then
214 we check if a C<photo_type> has been set in this user's record. If so,
215 then we'll use that as the content type for this request, and the data
216 in the C<photo> attribute as the data to send out. The trick here is
217 that setting C<$r-E<gt>{output}> overrides the whole view class processing
218 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 C<photo>, it's
227 now worth optimizing the C<User> class to ensure that only pertitent
228 data is fetched by default, and C<photo> and friends are only fetched on
229 demand. The "lazy population" section of L<Class::DBI>'s man page
230 explains how to group the columns by usage so that we can optimize
231 fetches:
232
233     Flox::User->columns(Primary   => qw/id/);
234     Flox::User->columns(Essential => qw/status/);
235     Flox::User->columns(Helpful   => qw/ first_name last_name email password/)
236     Flox::User->columns(Display   => qw/ profile affiliation unistatus /);
237     Flox::User->columns(Photo     => qw/ photo photo_type /);
238
239 This means that the status and ID columns will always be retrieved when
240 we deal with a user; next, any one of the name, email or password
241 columns will cause that group of data to be retrieved; if we go on to
242 display more information about a user, we also load up the profile,
243 affiliation and university status; finally, if we're throwing around
244 photos, then we load in the photo type and photo data.
245
246 These groupings are somewhat arbitrary, and there needs to be a lot of
247 profiling to determine the most efficient groupings of columns to load,
248 but they demonstrate one principle about working in Maypole: this is the
249 first time in dealing with Maypole that we've had to explicitly list the
250 columns of a table, but Maypole has so far Just Worked. There's a
251 difference, though, between Maypole just working and Maypole working
252 well, and if you want to optimize your application, then you need to
253 start putting in the code to do that. The beauty of Maypole is that you
254 can do as much or as little of such optimization as you want 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 =head2 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 C<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 "Uploading
277 files" recipe, with an additional check to make sure the photo is of a
278 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 moment.
308 Let's have a look at the real meat of a social networking site: getting
309 other people involved, and registering connections between users. 
310
311 =head2 Invitations
312
313 We need to do two things to make invitations work: first provide a way
314 to issue an invitation, and then provide a way to accept it. Since what
315 we're doing in issuing an invitation is essentially creating a new
316 one, we'll use our usual practice of having a page to display the form
317 to offer an invitation, and then use a C<do_edit> method to actually do
318 the work. So our C<issue> method is just an empty action:
319
320     sub issue :Exported {}
321
322 and the template proceeds as normal:
323
324     [% PROCESS header %]
325     <h2> Invite a friend </h2>
326
327     <FORM ACTION="[%base%]/invitation/do_edit/" METHOD="post">
328     <TABLE>
329
330 Now we use the "Catching errors in a form" recipe from the
331 L<Request Cookbook|Maypole::Manual::Request> and
332 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 C<do_edit> action. This has to validate the
352 form parameters, create the invited user, create the row in the C<invitation>
353 table, and send an email to the new user asking them to join.
354
355 We'd normally use C<create_from_cgi> to do the first two stages, but this time
356 we handle the untainting manually, because there are a surprising number of
357 things we need to check before we actually do the create. So here's the
358 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 case
371 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, C<message>, which we'll use
382 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 user's 
387 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 C<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 currently
409 doing a C</invitation/do_edit/> and we want to turn this into a
410 C</user/view/xxx>, changing the table, template and arguments all at once.
411 To do this, we have to change the Maypole request object's idea of the model
412 class, since this determines where to look for the template: if we didn't,
413 we'd end up with C<invitation/view> instead of C<user/view>.
414
415 Ideally, we'd do this with a Apache redirect, but we want to get that
416 C<message> in there as well, so this will have to do. This isn't good practice;
417 we put it into a subroutine so that we can fix it up if we find a better way
418 to do it.
419
420 Anyway back in the C<do_edit> action, 
421 this is what we should do if a user already exists on the system
422 and has accepted an invite already. What if we're trying to invite a user but
423 someone else has invited them first and they haven't replied yet?
424
425              } else {
426                 # Put it back to the form
427                 $r->template_args->{message} =
428                     "That user has already been invited; " .
429                     "please wait for them to accept";
430                 $r->template("issue");
431              }
432              return;
433         }
434
435 Race conditions suck.
436
437 Okay. Now we know that the user doesn't exist, and so can create the new 
438 one:
439
440         my $new_user = Flox::User->create({
441             email      => $ex{email},
442             first_name => $ex{forename},
443             last_name  => $ex{surname},
444             status     => "invitee"
445         });
446
447 We want to give the invitee a URL that they can go to in order to
448 accept the invite. Now we don't just want the IDs of our invites to
449 be sequential, since someone could get one invite, and then guess the
450 rest of the invite codes. We provide a relatively secure MD5 hash as
451 the invite ID:
452
453         my $random = md5_hex(time.(0+{}).$$.rand);
454
455 For additional security, we're going to have the URL in the form
456 C</invitation/accept/I<id>/I<from_id>/I<to_id>>, encoding the user ids
457 of the two users. Now we can send email to the invitee to ask them to
458 visit that URL:
459
460         my $newid = $new_user->id;
461         my $myid  = $r->user->id;
462         _send_mail(to   => $ex{email},
463                    url  => "$random/$myid/$newid", 
464                    user => $r->user);
465
466 I'm not going to show the C<_send_mail> routine, since it's boring.
467 We haven't actually created the C<Invitation> object yet, so let's
468 do that now.
469
470         Flox::Invitation->create({
471             id        => $random,
472             issuer    => $r->user,
473             recipient => $new_user,
474             expires   => Time::Piece->new(time + LIFETIME)->datetime
475         });
476
477 You can also imagine a daily cron job that cleans up the C<Invitation>
478 table looking for invitations that ever got replied to within their
479 lifetime:
480
481    ($_->expires > localtime && $_->delete)
482        for Flox::Invitation->retrieve_all;
483
484 Notice that we don't check whether the ID is already used. We could, but,
485 you know, if MD5 sums start colliding, we have much bigger problems on
486 our hands.
487
488 Anyway, now we've got the invitation created, we can go back to whence we
489 came: viewing the original user:
490
491         $self->redirect_to_user($r, $r->user);
492
493 Now our invitee has an email, and goes B<click> on the URL. What happens?
494
495 XXX
496
497 =head2 Friendship Connections
498
499 XXX
500
501 =head2 Links
502
503 The source for Flox is available at
504 L<http://cvs.simon-cozens.org/viewcvs.cgi/flox>.
505
506 L<Contents|Maypole::Manual>,
507 Next L<The Maypole iBuySpy Portal|Maypole::Manual::BuySpy>,
508 Previous L<Maypole Request Hacking Cookbook|Maypole::Manual::Cookbook>
509