# Controller with File Upload
## Synopsis ##
Almost all of my projects deal with image-, video- or audiofiles. This article explains how to create a controller that provides CRUD functionality for resources with file uploads.
The following modules are used:
- `Moose::Role`
- `HTML::FormHandler`
- `DBIx::Class::InflateColumn::FS`
- `DBIx::Class::Ordered`
and some more. This article has 3 attached Moose roles which you can download and put in `lib/CatalystX/...`
Uploaded files will be delivered using the `X-Sendfile` header (<http://www.catalystframework.org/calendar/2009/16>)
## Moose Action Roles ##
Because I want different controllers to have the same functionality and I want to stay DRY I use `Moose::Role` to compose some actions into my controller.
The roles are named `Resource.pm` and `SortableResource.pm`
You can [download these files](http://wiki.catalystframework.org/wiki/wikicookbook/ControllerWithFileUpload.attachment/100).
This is how my lib structure looks like:
lib/
CatalystX
Role
Sendfile.pm
TraitFor
Controller
Resource.pm
SortableResource.pm
MyApp/
MyApp.pm
MIMETypes.pm
This leads to almost no code in the actual controller files.
Let's create a Blog Application.
## Catalyst Configuration ##
`lib/Blog.pm`
<pre lang="Perl">
__PACKAGE__->config(
name => 'Blog',
# Disable deprecated behavior needed by old applications
disable_component_resolution_regex_fallback => 1,
default_view => 'TT',
default_model => 'DB',
session => { flash_to_stash => 1 },
'Model::DB' => {
fs_path => __PACKAGE__->path_to( qw/ root static media/ ),
schema_class => 'Blog::Schema',
connect_info => {
dsn => 'dbi:Pg:database=blog',
user => 'blog',
password => 'blog_password',
AutoCommit => 1,
},
},
...
);
</pre>
A Blog has many `Article`s and each` Article` can have `Media` attached.
## Articles Result ##
<pre lang="Perl">
package Blog::Schema::Result::Articles;
use strict;
use warnings;
use base 'DBIx::Class';
__PACKAGE__->load_components(qw/
Ordered
InflateColumn::Object::Enum
InflateColumn::DateTime
TimeStamp
Core
/);
__PACKAGE__->table("articles");
__PACKAGE__->add_columns(
"id",
{
data_type => "INTEGER",
is_auto_increment => 1,
is_numeric => 1,
is_nullable => 0,
},
"title",
{
data_type => "TEXT",
is_nullable => 0,
},
"body",
{
data_type => "TEXT",
is_nullable => 0,
},
"position",
{
data_type => "INTEGER",
default_value => 0,
is_nullable => 1,
},
);
__PACKAGE__->resultset_attributes({ order_by => 'position' });
__PACKAGE__->position_column('position');
__PACKAGE__->add_unique_constraint([ qw/ position / ]);
__PACKAGE__->set_primary_key("id");
__PACKAGE__->has_many(
'media',
'Blog::Schema::Result::Media',
'article_id',
{ cascade_delete => 1 }
);
# overide delete to make sure the associated file is deleted aswell
sub delete {
my $self = shift;
$self->media->delete_all;
$self->next::method();
}
1;
</pre>
## Articles Controller ##
<pre lang="Perl">
package Blog::Controller::Articles;
use Moose;
use namespace::autoclean;
use Blog::Form::Articles::Create;
BEGIN { extends 'Catalyst::Controller' }
with 'Blog::Role::SortableResource';
__PACKAGE__->config(
resultset_key => 'articles_rs',
resources_key => 'articles',
resource_key => 'article',
model => 'DB::Articles',
form_class => 'Blog::Form::Articles::Create',
template => 'articles/form.tt',
actions => {
base => { PathPart => ['article'], Chained => [''], CaptureArgs => 0 },
},
);
__PACKAGE__->meta->make_immutable;
1;
</pre>
## Articles Form ##
The form configuration:
<pre lang="Perl">
package Blog::Form::Articles::Create;
use HTML::FormHandler::Moose;
extends 'HTML::FormHandler::Model::DBIC';
use namespace::autoclean;
has '+item_class' => ( default => 'Articles' );
has '+enctype' => ( default => 'multipart/form-data');
has_field 'title' => (
type => 'Text',
required => 1,
size => 25,
minlength => 5,
);
has_field 'body' => (
type => 'TextArea',
required => 1,
minlength => 5,
);
has_field 'submit' => ( id => 'btn_submit', type => 'Submit', value => 'Submit' );
__PACKAGE__->meta->make_immutable;
1;
</pre>
So far we can create Articles. Next step is adding a Controller for Media that can be added to Articles.
## Media Result ##
First we need the database resultsource:
<pre lang="Perl">
package Blog::Schema::Result::Media;
use strict;
use warnings;
use base 'DBIx::Class';
__PACKAGE__->load_components(qw/
Ordered
InflateColumn::Object::Enum
InflateColumn::DateTime
InflateColumn::FS
TimeStamp
Core
/);
__PACKAGE__->table("media");
__PACKAGE__->add_columns(
"id",
{
data_type => "INTEGER",
is_auto_increment => 1,
is_numeric => 1,
is_nullable => 0,
},
"article_id",
{
data_type => "INTEGER",
is_numeric => 1,
is_nullable => 0,
},
"title",
{
data_type => "TEXT",
is_nullable => 0,
},
"description",
{
data_type => "TEXT",
is_nullable => 1,
},
"position",
{
data_type => "INTEGER",
default_value => 0,
is_nullable => 1,
},
"file",
{
data_type => "TEXT",
is_fs_column => 1,
#fs_column_path => '/tmp', # we set this value in config in lib/MyApp.pm
},
"content_type",
{
data_type => "varchar",
size => 32,
is_nullable => 0,
},
"mediatype",
{
data_type => "varchar",
is_nullable => 0,
is_enum => 1,
extra => { list => [qw/image audio video/] },
},
"created",
{
data_type => 'datetime',
set_on_create => 1,
is_nullable => 0,
},
"updated",
{
data_type => 'datetime',
set_on_update => 1,
set_on_create => 1,
is_nullable => 0,
},
);
__PACKAGE__->resultset_attributes({ order_by => 'position' });
__PACKAGE__->position_column('position');
__PACKAGE__->add_unique_constraint([ qw/ position / ]);
__PACKAGE__->set_primary_key("id");
__PACKAGE__->belongs_to(
"article",
"article",
"Blog::Schema::Result::Articles",
"article_id",
);
1;
</pre>
## X-Sendfile ##
The `Media` controller consumes the `Sendfile` role.
First install the apache xsendfile module. If you use debian that's easy.
aptitude install libapache2-mod-xsendfile
Then you need to enable X-Sendfile support in your apache config like so:
XSendFile On
## Media Controller ##
The `Sendfile` role injects a `$self->sendfile($c, $filename[, $content_type])` method into the controller.
<pre lang="Perl">
package Blog::Controller::Media;
use Moose;
use namespace::autoclean;
use Blog::Form::Media::Create;
BEGIN { extends 'Catalyst::Controller' }
with 'Blog::Role::Sendfile';
with 'Blog::Role::SortableResource';
__PACKAGE__->config(
parent_key => 'article',
parents_accessor => 'media',
resultset_key => 'media_rs',
resources_key => 'media',
resource_key => 'm',
model => 'DB::Media',
form_class => 'Blog::Form::Media::Create',
template => 'media/form.tt',
actions => {
base => { PathPart => ['media'], Chained => ['/articles/base_with_id'], CaptureArgs => 0 },
},
);
# set the file param so HTML::FormHandler can work its magic
before 'form' => sub {
my ( $self, $c, $media ) = @_;
if ($c->req->method eq 'POST') {
$c->req->params->{'file'} = $c->req->upload('file');
}
};
sub send : Chained('base_with_id') PathPart('send') Args(0) {
my ( $self, $c ) = @_;
my $media = $c->stash->{m};
$self->sendfile($c, $media->file, $media->content_type);
}
__PACKAGE__->meta->make_immutable;
1;
</pre>
With this controller you can serve images/videos/songs/... with the following link format:
<img alt="[% m.title %]" src="[% c.uri_for(c.controller('Media').action_for('send'), [ article.id, m.id ] ) %]" />
## Media Form ##
Here is the Form source: `Blog/Form/Media/Create.pm`
<pre lang="Perl">
package Blog::Form::Media::Create;
use HTML::FormHandler::Moose;
extends 'HTML::FormHandler::Model::DBIC';
use namespace::autoclean;
has '+item_class' => ( default => 'Media' );
has '+enctype' => ( default => 'multipart/form-data');
has_field 'title' => (
type => 'Text',
required => 1,
size => 25,
minlength => 5,
);
has_field 'description' => (
type => 'TextArea',
required => 1,
minlength => 5,
);
has_field 'file' => (
type => 'Upload',
required => 1,
max_size => 10000000,
);
has_field 'submit' => ( id => 'btn_submit', type => 'Submit', value => 'Submit' );
# after validation we want { file => $filehandle }
# instead of { file => $catalyst_request_upload }
sub validate {
my $self = shift;
if (defined($self->field('file')->value)) {
my $fh = $self->field('file')->value->fh;
$self->field('file')->value($fh);
}
}
use MIME::Types;
has '_mime_types' => (
is => 'ro',
default => sub {
my $mime = MIME::Types->new( only_complete => 1 );
$mime->create_type_index;
$mime;
}
);
before 'update_model' => sub {
my $self = shift;
my $item = $self->item;
my $file = $self->params->{file};
return unless($file); # file field in HFH is inactive
use MIMETypes;
my $mime = MIMETypes::MIMEfromFile($file->basename);
my ($type, $subtype) = split('/', $mime);
$item->content_type($mime);
$item->mediatype($type);
};
__PACKAGE__->meta->make_immutable;
1;
</pre>
## Prerequisites ##
One more thing to note here. For this code to work you need to read and apply the [[/WikiCookbook/ConfigPass2Schema| instructions for passing config to your schema]].
## Credits ##
t0m, gshank, [puisi islam](http://goo.gl/ayRVkp), semifor, rafl, castaway, caelum, hobbs, mst, ... and probably some more.
t0m, gshank, semifor, rafl, castaway, caelum, hobbs, mst, ... and probably some more.