package Mojolicious::Static;
use Mojo::Base -base;

use File::Spec::Functions 'catfile';
use Mojo::Asset::File;
use Mojo::Asset::Memory;
use Mojo::Home;
use Mojo::Loader;

has classes => sub { ['main'] };
has paths   => sub { [] };

# Last modified default
my $MTIME = time;

# Bundled files
my $HOME   = Mojo::Home->new;
my $PUBLIC = $HOME->parse($HOME->mojo_lib_dir)->rel_dir('Mojolicious/public');

sub dispatch {
  my ($self, $c) = @_;

  # Canonical path
  my $stash = $c->stash;
  my $path  = $c->req->url->path;
  $path = $stash->{path} ? $path->new($stash->{path}) : $path->clone;
  return undef unless my @parts = @{$path->canonicalize->parts};

  # Serve static file and prevent directory traversal
  return undef if $parts[0] eq '..' || !$self->serve($c, join('/', @parts));
  $stash->{'mojo.static'}++;
  return !!$c->rendered;
}

sub file {
  my ($self, $rel) = @_;

  # Search all paths
  for my $path (@{$self->paths}) {
    next unless my $asset = $self->_get_file(catfile $path, split('/', $rel));
    return $asset;
  }

  # Search DATA
  if (my $asset = $self->_get_data_file($rel)) { return $asset }

  # Search bundled files
  return $self->_get_file(catfile($PUBLIC, split('/', $rel)));
}

sub serve {
  my ($self, $c, $rel) = @_;
  return undef unless my $asset = $self->file($rel);
  my $type = $rel =~ /\.(\w+)$/ ? $c->app->types->type($1) : undef;
  $c->res->headers->content_type($type || 'text/plain');
  return !!$self->serve_asset($c, $asset);
}

sub serve_asset {
  my ($self, $c, $asset) = @_;

  # Last modified
  my $mtime = $asset->is_file ? (stat $asset->path)[9] : $MTIME;
  my $res = $c->res;
  $res->code(200)->headers->last_modified(Mojo::Date->new($mtime))
    ->accept_ranges('bytes');

  # If modified since
  my $headers = $c->req->headers;
  if (my $date = $headers->if_modified_since) {
    my $since = Mojo::Date->new($date)->epoch;
    return $res->code(304) if defined $since && $since == $mtime;
  }

  # Range
  my $size  = $asset->size;
  my $start = 0;
  my $end   = $size - 1;
  if (my $range = $headers->range) {

    # Not satisfiable
    return $res->code(416) unless $size && $range =~ m/^bytes=(\d+)?-(\d+)?/;
    $start = $1 if defined $1;
    $end = $2 if defined $2 && $2 <= $end;
    return $res->code(416) if $start > $end || $end > ($size - 1);

    # Satisfiable
    $res->code(206)->headers->content_length($end - $start + 1)
      ->content_range("bytes $start-$end/$size");
  }

  return $res->content->asset($asset->start_range($start)->end_range($end));
}

sub _get_data_file {
  my ($self, $rel) = @_;

  # Protect templates
  return undef if $rel =~ /\.\w+\.\w+$/;

  # Index DATA files
  my $loader = Mojo::Loader->new;
  unless ($self->{index}) {
    my $index = $self->{index} = {};
    for my $class (reverse @{$self->classes}) {
      $index->{$_} = $class for keys %{$loader->data($class)};
    }
  }

  # Find file
  return undef
    unless defined(my $data = $loader->data($self->{index}{$rel}, $rel));
  return Mojo::Asset::Memory->new->add_chunk($data);
}

sub _get_file {
  my ($self, $path) = @_;
  no warnings 'newline';
  return -f $path && -r $path ? Mojo::Asset::File->new(path => $path) : undef;
}

1;

=encoding utf8

=head1 NAME

Mojolicious::Static - Serve static files

=head1 SYNOPSIS

  use Mojolicious::Static;

  my $static = Mojolicious::Static->new;
  push @{$static->classes}, 'MyApp::Foo';
  push @{$static->paths}, '/home/sri/public';

=head1 DESCRIPTION

L<Mojolicious::Static> is a static file server with C<Range> and
C<If-Modified-Since> support.

=head1 ATTRIBUTES

L<Mojolicious::Static> implements the following attributes.

=head2 classes

  my $classes = $static->classes;
  $static     = $static->classes(['main']);

Classes to use for finding files in C<DATA> sections, first one has the
highest precedence, defaults to C<main>.

  # Add another class with static files in DATA section
  push @{$static->classes}, 'Mojolicious::Plugin::Fun';

=head2 paths

  my $paths = $static->paths;
  $static   = $static->paths(['/home/sri/public']);

Directories to serve static files from, first one has the highest precedence.

  # Add another "public" directory
  push @{$static->paths}, '/home/sri/public';

=head1 METHODS

L<Mojolicious::Static> inherits all methods from L<Mojo::Base> and implements
the following new ones.

=head2 dispatch

  my $bool = $static->dispatch(Mojolicious::Controller->new);

Serve static file for L<Mojolicious::Controller> object.

=head2 file

  my $asset = $static->file('images/logo.png');
  my $asset = $static->file('../lib/MyApp.pm');

Get L<Mojo::Asset::File> or L<Mojo::Asset::Memory> object for a file, relative
to L</"paths"> or from L</"classes">. Note that this method does not protect
from traversing to parent directories.

  my $content = $static->file('foo/bar.html')->slurp;

=head2 serve

  my $bool = $static->serve(Mojolicious::Controller->new, 'images/logo.png');
  my $bool = $static->serve(Mojolicious::Controller->new, '../lib/MyApp.pm');

Serve a specific file, relative to L</"paths"> or from L</"classes">. Note
that this method does not protect from traversing to parent directories.

=head2 serve_asset

  $static->serve_asset(Mojolicious::Controller->new, Mojo::Asset::File->new);

Serve a L<Mojo::Asset::File> or L<Mojo::Asset::Memory> object with C<Range>
and C<If-Modified-Since> support.

=head1 SEE ALSO

L<Mojolicious>, L<Mojolicious::Guides>, L<http://mojolicio.us>.

=cut
