File Coverage

blib/lib/Mojolicious/Plugin/Shotwell.pm
Criterion Covered Total %
statement 19 21 90.4
branch n/a
condition n/a
subroutine 7 7 100.0
pod n/a
total 26 28 92.8


line stmt bran cond sub pod time code
1             package Mojolicious::Plugin::Shotwell;
2              
3             =head1 NAME
4              
5             Mojolicious::Plugin::Shotwell - View photos from Shotwell database
6              
7             =head1 VERSION
8              
9             0.05
10              
11             =head1 SYNOPSIS
12              
13             use Mojolicious::Lite;
14              
15             # allow /shotwell/... resources to be protected by login
16             my $protected = under '/shotwell' => sub {
17             my $c = shift;
18             return 1 if $c->session('username') or $c->shotwell_access_granted;
19             $c->render('login');
20             return 0;
21             };
22              
23             plugin shotwell => {
24             dbname => '/home/username/.local/share/shotwell/data/photo.db',
25             routes => {
26             default => $protected,
27             permalink => app->routes->get('/:permalink'), # not protected
28             }
29             };
30              
31             app->start;
32              
33             This module can also be tested from command line if you have the defaults set
34             up:
35              
36             $ perl -Mojo -e'plugin "shotwell"; app->start' daemon
37              
38             =head1 DESCRIPTION
39              
40             This plugin provides actions which can render data from a
41             L database:
42              
43             =over 4
44              
45             =item * Events
46              
47             See L and L.
48              
49             =item * Tags
50              
51             See L and L.
52              
53             =item * Thumbnails
54              
55             See L.
56              
57             =item * Photos
58              
59             See L and L.
60              
61             =back
62              
63             =cut
64              
65 3     3   7241 use Mojo::Base 'Mojolicious::Plugin';
  3         5  
  3         25  
66 3     3   672 use Mojo::Util qw/ decode md5_sum /;
  3         7  
  3         227  
67 3     3   16 use File::Basename qw/ basename dirname /;
  3         6  
  3         195  
68 3     3   16 use File::Spec::Functions qw/ catdir /;
  3         5  
  3         140  
69 3     3   17122 use DBI;
  3         106452  
  3         255  
70 3     3   3699 use Image::EXIF;
  3         4171  
  3         126  
71 3     3   1473 use Image::Imlib2;
  0            
  0            
72             use constant DEBUG => $ENV{MOJO_SHOTWELL_DEBUG} ? 1 : 0;
73             use constant DEFAULT_DBI_ATTRS => { RaiseError => 1, PrintError => 0, AutoCommit => 1 };
74             use constant SHOTWELL_PERMALINK => 'spl';
75             use constant SPECIAL_BASENAME => md5_sum(time .$$ .rand 9999999);
76              
77             our $VERSION = '0.05';
78             our %SST;
79              
80             {
81             my($sst, $k);
82             while() {
83             if(/^---\s(\w+)/) {
84             $SST{$k} = $sst if $k and $sst;
85             $k = $1;
86             $sst = '';
87             }
88             elsif($k and /\S/) {
89             $sst .= $_;
90             }
91             }
92             }
93              
94             sub _DUMP {
95             my($format, $arg) = @_;
96             require Data::Dumper;
97             printf "$format\n", Data::Dumper::Dumper($arg);
98             }
99              
100             =head1 ATTRIBUTES
101              
102             =head2 cache_dir
103              
104             Path to where all the scaled/rotated images gets stored. Defaults to
105             "/tmp/shotwell". This can be overridden in L:
106              
107             $self->register($app, { cache_dir => '/some/path' });
108              
109             =cut
110              
111             has cache_dir => sub {
112             my $dir = '/tmp/shotwell';
113             mkdir $dir;
114             return $dir;
115             };
116              
117             =head2 dsn
118              
119             Returns argument for L. Default is
120              
121             dbi:SQLite:dbname=$HOME/.local/share/shotwell/data/photo.db
122              
123             C<$HOME> is the C environment variable. The default dsn can be
124             overridden by either giving "dsn" or "dbname" to L. Example:
125              
126             $self->register($app, { dbname => $path_to_db_file });
127              
128             =cut
129              
130             has dsn => sub {
131             my $home = $ENV{HOME} || '';
132             "dbi:SQLite:dbname=$home/.local/share/shotwell/data/photo.db";
133             };
134              
135             =head2 sizes
136              
137             The size of the photos generated by L and L. Default is:
138              
139             {
140             inline => [ 1024, 0 ], # 0 = scale
141             thumb => [ 100, 100 ],
142             }
143              
144             This can be overridden in L:
145              
146             $self->register($app, { sizes => { thumb => [200, 200], ... } });
147              
148             =cut
149              
150             has sizes => sub {
151             +{
152             inline => [ 1024, 0 ],
153             thumb => [ 100, 100 ],
154             };
155             };
156              
157             has _types => sub { Mojolicious::Types->new };
158             has _log => sub { Mojo::Log->new };
159              
160             =head1 ACTIONS
161              
162             =head2 events
163              
164             Default route: C.
165              
166             Render data from EventTable. Data is rendered as JSON or defaults to a
167             template by the name "templates/shotwell/events.html.ep".
168              
169             JSON data:
170              
171             [
172             {
173             id => $int,
174             name => $str,
175             time_created => $epoch,
176             url => $shotwell_event_url,
177             },
178             ...
179             ]
180              
181             The JSON data is also available in the template as C<$events>.
182              
183             =cut
184              
185             sub events {
186             my($self, $c) = @_;
187             my $sth = $self->_sth($c, 'events');
188             my @events;
189              
190             while(my $event = $sth->fetchrow_hashref('NAME_lc')) {
191             (my $name = $event->{name}) =~ s/\W//g; # /
192             push @events, {
193             id => int $event->{id},
194             name => decode('UTF-8', $event->{name}),
195             time_created => $event->{time_created},
196             url => $c->url_for(
197             'shotwell/event' => (
198             id => $event->{id},
199             format => $c->stash('format'),
200             name => $name,
201             )
202             ),
203             };
204             }
205              
206             $c->respond_to(
207             json => sub { shift->render(json => \@events) },
208             any => sub { shift->render(events => \@events); }
209             );
210             }
211              
212             =head2 event
213              
214             Default route: C.
215              
216             Render photos from PhotoTable, by a given event id. Data is rendered as JSON
217             or defaults to a template by the name "templates/shotwell/event.html.ep".
218              
219             JSON data:
220              
221             [
222             {
223             id => $int,
224             size => $int,
225             title => $str,
226             raw => $shotwell_raw_url,
227             thumb => $shotwell_thumb_url,
228             url => $shotwell_show_url,
229             },
230             ...
231             ]
232              
233             The JSON data is also available in the template as C<$photos>.
234              
235             =cut
236              
237             sub event {
238             my($self, $c) = @_;
239             my $sth = $self->_sth($c, event => $c->stash('id'));
240             my $row = $sth->fetchrow_hashref or return $c->render_not_found;
241              
242             $c->stash(name => decode('UTF-8', $row->{name}));
243              
244             if($c->param('permalink')) {
245             $self->_permalink_create($c, collection => $self->_event_to_photo_ids($c));
246             }
247             else {
248             $self->_photos($c, photos_by_event_id => $c->stash('id'));
249             }
250             }
251              
252             sub _event_to_photo_ids {
253             my($self, $c) = @_;
254             my $sth = $self->_sth($c, photos_by_event_id => $c->stash('id'));
255             my $ids = [];
256              
257             while(my $photo = $sth->fetchrow_hashref('NAME_lc')) {
258             push @$ids, $photo->{id};
259             }
260              
261             return $ids;
262             }
263              
264             =head2 permalink
265              
266             Default route: C.
267              
268             Will either render the same as L or L, dependent on the
269             type of permalink.
270              
271             =cut
272              
273             sub permalink {
274             my($self, $c) = @_;
275             my $sth = $self->_sth($c, permalink => $c->stash('permalink'));
276             my $row = $sth->fetchrow_hashref or return $c->render_not_found;
277             my @ids = split /,/, $row->{foreign_ids};
278              
279             # store it for shotwell_access_granted() helper usage
280             $c->session(SHOTWELL_PERMALINK, $c->stash('permalink'));
281              
282             if($row->{type} eq 'collection') {
283             warn "[SHOTWELL] render collection from permalink\n" if DEBUG;
284             $c->stash(
285             comment => $row->{comment},
286             template => 'shotwell/event',
287             foreign_ids => \@ids,
288             );
289             $self->_photos($c, sprintf($SST{photos_by_ids}, join ',', map { '?' } @ids), @ids);
290             }
291             else {
292             warn "[SHOTWELL] render single image from permalink\n" if DEBUG;
293             $c->stash(
294             id => $ids[0],
295             basename => SPECIAL_BASENAME,
296             comment => $row->{comment},
297             foreign_ids => \@ids,
298             template => 'shotwell/show',
299             );
300             $self->show($c);
301             }
302             }
303              
304             =head2 permalink_delete
305              
306             Default route: C.
307              
308             Used to delete a permalink from backend.
309              
310             =cut
311              
312             sub permalink_delete {
313             my($self, $c) = @_;
314             my $sth = $self->_sth($c, permalink_delete => $c->stash('permalink'));
315              
316             if($sth->rows) {
317             delete $c->session->{SHOTWELL_PERMALINK()};
318             $c->respond_to(
319             json => sub { shift->render(json => {}) },
320             any => sub { shift->render },
321             );
322             }
323             else {
324             $c->render_not_found;
325             }
326             }
327              
328             =head2 tags
329              
330             Default route: C.
331              
332             Render data from TagTable. Data is rendered as JSON or defaults to a template
333             by the name "templates/shotwell/tags.html.ep".
334              
335             JSON data:
336              
337             [
338             {
339             name => $str,
340             url => $shotwell_tag_url,
341             },
342             ...
343             ]
344              
345             The JSON data is also available in the template as C<$tags>.
346              
347             =cut
348              
349             sub tags {
350             my($self, $c) = @_;
351             my $sth = $self->_sth($c, 'tags');
352             my @tags;
353              
354             while(my $tag = $sth->fetchrow_hashref) {
355             my $name = decode('UTF-8', $tag->{name});
356             push @tags, {
357             name => $name,
358             url => $c->url_for('shotwell/tag' => name => $name, format => $c->stash('format')),
359             };
360             }
361              
362             $c->respond_to(
363             json => sub { shift->render(json => \@tags) },
364             any => sub { shift->render(tags => \@tags) },
365             );
366             }
367              
368             =head2 tag
369              
370             Default route: C.
371              
372             Render photos from PhotoTable, by a given tag name. Data is rendered as JSON
373             or defaults to a template by the name "templates/shotwell/tag.html.ep".
374              
375             The JSON data is the same as for L.
376              
377             =cut
378              
379             sub tag {
380             my($self, $c) = @_;
381             my $sth = $self->_sth($c, photo_id_list_by_tag_name => $c->stash('name'));
382             my $row = $sth->fetchrow_hashref or return $c->render_not_found;
383             my @ids = map { s/thumb0*//; hex } grep { /^thumb/ } split /,/, $row->{photo_id_list} || '';
384              
385             if($c->param('permalink')) {
386             $self->_permalink_create($c, collection => \@ids);
387             }
388             else {
389             $self->_photos($c, sprintf($SST{photos_by_ids}, join ',', map { '?' } @ids), @ids);
390             }
391             }
392              
393             =head2 raw
394              
395             Default route: C.
396              
397             Render raw photo.
398              
399             =cut
400              
401             sub raw {
402             my($self, $c) = @_;
403             my $photo = $self->_photo($c) or return;
404             my $file = $photo->{filename};
405             my $static;
406              
407             if($c->param('download')) {
408             my $basename = basename $file;
409             $c->res->headers->content_disposition(qq(attachment; filename="$basename"));
410             }
411             if($c->param('inline')) {
412             $file = $self->_scale_photo($photo, $self->sizes->{inline});
413             }
414              
415             $static = Mojolicious::Static->new(paths => [dirname $file]);
416              
417             return $c->rendered if $static->serve($c, basename $file);
418             return $c->render_exception("Unable to serve ($file)");
419             }
420              
421             =head2 show
422              
423             Default route: C.
424              
425             Render a template with an photo inside. The name of the template is
426             "templates/shotwell/show.html.ep".
427              
428             The stash data is the same as one element described for L JSON data.
429              
430             =cut
431              
432             sub show {
433             my($self, $c, $skip_permalink) = @_;
434             my $photo = $self->_photo($c) or return;
435              
436             if(!$c->stash('foreign_ids') and $c->param('permalink')) {
437             return $self->_permalink_create($c, single => [$photo->{id}]);
438             }
439              
440             $c->render(
441             size => $photo->{filesize} || 0,
442             title => decode('UTF-8', $photo->{title} || $c->stash('basename')),
443             raw => $c->url_for('shotwell/raw', %$photo),
444             thumb => $c->url_for('shotwell/thumb', %$photo),
445             url => $c->url_for('shotwell/show', %$photo),
446             );
447             }
448              
449             =head2 thumb
450              
451             Default route: C.
452              
453             Render photo as a thumbnail.
454              
455             =cut
456              
457             sub thumb {
458             my($self, $c) = @_;
459             my $photo = $self->_photo($c) or return;
460             my $file = $self->_scale_photo($photo, $self->sizes->{thumb});
461             my $static = Mojolicious::Static->new(paths => [dirname $file]);
462              
463             return $c->rendered if $static->serve($c, basename $file);
464             return $c->render_exception("Unable to serve ($file)");
465             }
466              
467             sub _permalink_create {
468             my($self, $c, $type, $ids) = @_;
469             my $sth = $self->_sth($c, 'permalink_create', { execute => 0 });
470             my $comment = $c->tx->remote_address || '';
471             my $time = time;
472             my $permalink;
473              
474             $ids = join ',', @$ids;
475              
476             do {
477             $permalink = substr md5_sum($time. $$. rand 99999), 2, 15; # 2 and 15 is not chosen for any special reason
478             eval { $sth->execute($time, $permalink, $type, $ids, $comment) };
479             } while($@);
480              
481             $c->session(SHOTWELL_PERMALINK, $permalink);
482             $c->redirect_to('shotwell/permalink', permalink => $permalink);
483             }
484              
485             sub _photo {
486             my($self, $c) = @_;
487             my $sth = $self->_sth($c, photo_by_id => $c->stash('id'));
488             my $photo = $sth->fetchrow_hashref;
489             my $basename;
490              
491             if(!$photo) {
492             warn "[SHOTWELL] Could not find photo by id\n" if DEBUG;
493             $c->render_not_found;
494             return;
495             }
496              
497             $photo->{filename} ||= '';
498             $basename = basename $photo->{filename};
499              
500             if($c->stash('basename') ne $basename and $c->stash('basename') ne SPECIAL_BASENAME) {
501             _DUMP 'photo=%s', $photo if DEBUG;
502             $c->render_exception("Invalid basename: $basename");
503             return;
504             }
505              
506             $c->stash(basename => $basename);
507             $photo->{basename} = $basename;
508             $photo;
509             }
510              
511             sub _photos {
512             my($self, $c, @sth) = @_;
513             my $sth = $self->_sth($c, @sth);
514             my(@photos, @ids);
515              
516             while(my $photo = $sth->fetchrow_hashref('NAME_lc')) {
517             $photo->{basename} = basename $photo->{filename};
518             push @ids, $photo->{id};
519             push @photos, {
520             id => int $photo->{id},
521             size => int $photo->{filesize} || 0,
522             title => decode('UTF-8', $photo->{title} || $photo->{basename}),
523             raw => $c->url_for('shotwell/raw' => %$photo),
524             thumb => $c->url_for('shotwell/thumb' => %$photo),
525             url => $c->url_for('shotwell/show' => %$photo),
526             };
527             }
528              
529             $c->respond_to(
530             json => sub { shift->render(json => \@photos) },
531             any => sub { shift->render(photos => \@photos) },
532             );
533             }
534              
535             sub _more_photo_info {
536             my($self, $photo) = @_;
537              
538             $photo->{type} and return $photo;
539             $photo->{type} = $self->_types->type(lc $1) if $photo->{filename} =~ /\.(\w+)$/;
540             $photo->{type} ||= 'unknown';
541              
542             if($photo->{type} eq 'image/jpeg') {
543             $photo->{info} = Image::EXIF->new($photo->{filename})->get_image_info || {};
544             local $_ = $photo->{info}{'Image Orientation'} || '';
545              
546             if(/^.*left.*bottom/i) { $photo->{orientation} = 3 }
547             elsif(/^.*bottom.*right/i) { $photo->{orientation} = 2 }
548             elsif(/^.*right.*top/i) { $photo->{orientation} = 1 }
549             else { $photo->{orientation} = 0 }
550             }
551              
552             $photo->{height} ||= $photo->{info}{'Image Height'} || 0;
553             $photo->{width} ||= $photo->{info}{'Image Width'} || 0;
554             _DUMP 'info=%s', $photo if DEBUG;
555             $photo;
556             }
557              
558             sub _scale_photo {
559             my($self, $photo, $size) = @_;
560             my $out = sprintf '%s/%s-%sx%s', $self->cache_dir, md5_sum($photo->{filename}), @$size;
561              
562             if(-e $out) {
563             return $out;
564             }
565              
566             eval {
567             my $img = Image::Imlib2->load($photo->{filename});
568             $self->_more_photo_info($photo);
569             warn "[SHOTWELL] orientation=$photo->{orientation}\n" if DEBUG;
570             $img->image_orientate($photo->{orientation}) if $photo->{orientation};
571             warn "[SHOTWELL] create_scaled_image(@$size)\n" if DEBUG;
572             $img = $img->create_scaled_image(@$size);
573             $img->image_set_format('jpeg');
574             $img->save($out);
575             1;
576             } or do {
577             $self->_log->error("[Imlib2] $@");
578             $out = $photo->{filename};
579             };
580              
581             return $out;
582             }
583              
584             sub _sth {
585             my($self, $c, $key, @bind) = @_;
586             my $dbh = $c->stash->{'shotwell.dbh'} ||= DBI->connect(@{ $self->dsn });
587             my $sth;
588              
589             $sth = $dbh->prepare($SST{$key} || $key);
590              
591             unless(ref $bind[0]) {
592             warn "[SHOTWELL:DBI] @{[$SST{$key} || $key]}(@bind)\n---\n" if DEBUG;
593             $sth->execute(@bind) unless ref $bind[0];
594             }
595              
596             $sth;
597             }
598              
599             =head1 HELPERS
600              
601             =head2 shotwell_access_granted
602              
603             $bool = $c->shotwell_access_granted;
604              
605             Returns true if the L contains a
606             valid permalink id.
607              
608             =cut
609              
610             sub shotwell_access_granted {
611             my($self, $c) = @_;
612             my $permalink = $c->session(SHOTWELL_PERMALINK) or return 0;
613             my $sth = $self->_sth($c, permalink => $permalink);
614             my $row = $sth->fetchrow_hashref or return 0;
615              
616             if($c->req->url =~ m!/(\d+)/!) { # TODO: This is one ugly hack :(
617             my $id = $1;
618             return $row->{foreign_ids} =~ /\b$id\b/ ? 1 : 0;
619             }
620             else {
621             return 1;
622             }
623             }
624              
625             =head1 METHODS
626              
627             =head2 register
628              
629             $self->register($app, \%config);
630              
631             Set L and register L in the L application.
632              
633             =cut
634              
635             sub register {
636             my($self, $app, $config) = @_;
637             my $sizes = $self->sizes;
638              
639             $self->_log($app->log);
640             $self->_types($app->types);
641             $self->dsn("dbi:SQLite:dbname=$config->{dbname}") if $config->{dbname};
642             $app->helper(shotwell_access_granted => sub { $self->shotwell_access_granted(@_) });
643              
644             unless($config->{skip_bundled_templates}) {
645             push @{ $app->renderer->paths }, catdir dirname(__FILE__), 'Shotwell', 'templates';
646             }
647              
648             for my $k (qw/ dsn cache_dir /) {
649             $self->$k($config->{$k}) if $config->{$k};
650             }
651             for my $k (keys %$sizes) {
652             $sizes->{$k} = $config->{sizes}{$k} if $config->{sizes}{$k};
653             }
654              
655             if($config->{paths}) {
656             warn "/config/paths is replaced by /config/routes !!!";
657             $config->{routes} = delete $config->{paths}; # backward compat
658             }
659             if($config->{route}) {
660             warn "/config/route is replaced by /config/routes/default !!!";
661             $config->{routes}{default} = delete $config->{route}; # backward compat
662             }
663              
664             $self->dsn([ $self->dsn, '', '', DEFAULT_DBI_ATTRS ]) unless ref $self->dsn eq 'ARRAY';
665             $self->_create_missing_tables;
666             $self->_register_routes($app, %{ $config->{routes} || {} });
667             }
668              
669             sub _create_missing_tables {
670             my $self = shift;
671             my $dbh = DBI->connect(@{ $self->dsn });
672              
673             # create "permalinks" table unless it already exists
674             eval {
675             $dbh->prepare('SELECT COUNT(*) FROM mojolicious_plugin_shotwell_permalinks');
676             } or do {
677             warn $@ if DEBUG;
678             $dbh->prepare($SST{create_permalink_table})->execute;
679             $dbh->prepare($SST{create_permalink_index})->execute;
680             };
681             }
682              
683             sub _register_routes {
684             my($self, $app, %routes) = @_;
685              
686             $routes{default} ||= $app->routes;
687             $routes{events} ||= '/';
688             $routes{event} ||= '/event/:id/:name';
689             $routes{tags} ||= '/tags';
690             $routes{tag} ||= '/tag/:name';
691             $routes{raw} ||= '/raw/:id/*basename';
692             $routes{show} ||= '/show/:id/*basename';
693             $routes{thumb} ||= '/thumb/:id/*basename';
694             $routes{permalink} ||= '/:permalink';
695             $routes{permalink_delete} ||= '/:permalink/delete';
696              
697             for my $k (qw/ events event tags tag raw show thumb permalink permalink_delete /) {
698             warn "[SHOTWELL:ROUTE] $k => $routes{$k}\n" if DEBUG;
699             my $route = UNIVERSAL::isa($routes{$k}, 'Mojolicious::Routes::Route') ? $routes{$k} : $routes{default}->get($routes{$k});
700             $route->to(cb => sub { $self->$k(@_); })->name("shotwell/$k");
701             }
702             }
703              
704             =head1 DATABASE SCHEME
705              
706             =head2 EventTable
707              
708             id INTEGER PRIMARY KEY,
709             name TEXT,
710             primary_photo_id INTEGER,
711             time_created INTEGER,primary_source_id TEXT,
712             comment TEXT
713              
714             =head2 PhotoTable
715              
716             id INTEGER PRIMARY KEY,
717             filename TEXT UNIQUE NOT NULL,
718             width INTEGER,
719             height INTEGER,
720             filesize INTEGER,
721             timestamp INTEGER,
722             exposure_time INTEGER,
723             orientation INTEGER,
724             original_orientation INTEGER,
725             import_id INTEGER,
726             event_id INTEGER,
727             transformations TEXT,
728             md5 TEXT,
729             thumbnail_md5 TEXT,
730             exif_md5 TEXT,
731             time_created INTEGER,
732             flags INTEGER DEFAULT 0,
733             rating INTEGER DEFAULT 0,
734             file_format INTEGER DEFAULT 0,
735             title TEXT,
736             backlinks TEXT,
737             time_reimported INTEGER,
738             editable_id INTEGER DEFAULT -1,
739             metadata_dirty INTEGER DEFAULT 0,
740             developer TEXT,
741             develop_shotwell_id INTEGER DEFAULT -1,
742             develop_camera_id INTEGER DEFAULT -1,
743             develop_embedded_id INTEGER DEFAULT -1,
744             comment TEXT
745              
746             =head2 TagTable
747              
748             id INTEGER PRIMARY KEY,
749             name TEXT UNIQUE NOT NULL,
750             photo_id_list TEXT,
751             time_created INTEGER
752              
753             =head1 AUTHOR
754              
755             Jan Henning Thorsen - C
756              
757             =cut
758              
759             1;
760              
761             __DATA__