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