Group
Extension

WWW-Suffit-API/lib/WWW/Suffit/Server/API/NoAPI.pm

package WWW::Suffit::Server::API::NoAPI;
use strict;
use utf8;

=encoding utf8

=head1 NAME

WWW::Suffit::Server::API::NoAPI - The Suffit NoAPI controller

=head1 SYNOPSIS

    use WWW::Suffit::Server::API::NoAPI;

=head1 DESCRIPTION

The Suffit NoAPI controller

=head1 METHODS

List of internal methods

=head2 file_download

See L</"GET /api/file/FILEPATH">

=head2 file_list

See L</"GET /api/file">

=head2 file_remove

See L</"DELETE /api/file/FILEPATH">

=head2 file_upload

See L</"PUT /api/file/FILEPATH">

=head1 API METHODS

List of API methods

=head2 DELETE /api/file/FILEPATH

Remove file from server

    # curl -v -H "Authorization: Bearer eyJh...GBew" \
      -X DELETE \
      https://localhost:8695/api/file/foo/bar/test123.txt

    > DELETE /api/file/foo/bar/test123.txt HTTP/1.1
    > Host: localhost:8695
    > User-Agent: curl/7.68.0
    > Accept: */*
    > Authorization: Bearer eyJh...GBew
    >
    < HTTP/1.1 200 OK
    < Content-Length: 30
    < Content-Type: application/json;charset=UTF-8
    < Date: Wed, 14 Aug 2024 11:27:09 GMT
    < Server: OWL/1.11
    <
    {
      "code": "E0000",
      "status": true
    }

=head2 GET /api/file

Get a list of files on the server (manifest)

    # curl -v -H "Authorization: Bearer eyJh...s5aM" \
      https://localhost:8695/api/file

    > GET /api/file HTTP/1.1
    > Host: localhost:8695
    > User-Agent: curl/7.68.0
    > Accept: */*
    > Authorization: Bearer eyJh...s5aM
    >
    < HTTP/1.1 200 OK
    < Content-Length: 1081
    < Content-Type: application/json;charset=UTF-8
    < Date: Wed, 14 Aug 2024 11:17:42 GMT
    < Server: OWL/1.11
    < Vary: Accept-Encoding
    <
    {
      "documentroot": "/home/foo/tmp/owl/public",
      "manifest": [
        {
          "absf": "/",
          "directory": "/",
          "filename": "",
          "id": 9962787,
          "mdate": 1701365824,
          "path": "/",
          "perms": 509,
          "pid": 0,
          "size": 4096,
          "type": "folder"
        },
        {
          "absf": "/foo",
          "directory": "/",
          "filename": "foo",
          "id": 9962956,
          "mdate": 1690565423,
          "path": "foo",
          "perms": 509,
          "pid": 9962787,
          "size": 4096,
          "type": "folder"
        },
        {
          "absf": "/foo/bar",
          "directory": "/foo",
          "filename": "bar",
          "id": 9962957,
          "mdate": 1723634001,
          "path": "foo/bar",
          "perms": 509,
          "pid": 9962956,
          "size": 4096,
          "type": "folder"
        },
        {
          "absf": "/foo/bar/test123.txt",
          "directory": "/foo/bar",
          "filename": "test123.txt",
          "id": 9963558,
          "mdate": 1723634001,
          "path": "foo/bar/test123.txt",
          "perms": 436,
          "pid": 9962957,
          "size": 490,
          "type": "file"
        },
      ],
      "status": true
    }

=head2 GET /api/file/FILEPATH

Download file from server

    # curl -v -H "Authorization: Bearer eyJh...GBew" \
      https://localhost:8695/api/file/foo/bar/test123.txt

    > GET /api/file/foo/bar/test123.txt HTTP/1.1
    > Host: localhost:8695
    > User-Agent: curl/7.68.0
    > Accept: */*
    > Authorization: Bearer eyJh...GBew
    >
    < HTTP/1.1 200 OK
    < Accept-Ranges: bytes
    < Content-Length: 490
    < Content-Type: text/plain;charset=UTF-8
    < Date: Wed, 14 Aug 2024 11:24:32 GMT
    < ETag: "5946bf06113340241d59cbf2c940eede"
    < Last-Modified: Wed, 14 Aug 2024 11:13:21 GMT
    < Server: OWL/1.11
    <
    ...content of the file...

=head2 PUT /api/file/FILEPATH

Upload file to server

    # curl -v -H "Authorization: Bearer eyJh...GBew" \
      -X PUT -F size=490 \
      -F md5=acaa9dd36fe3b6758e91129170910378 -F fileraw=@test123.txt \
      https://localhost:8695/api/file/foo/bar/test123.txt

    > PUT /api/file/foo/bar/test123.txt HTTP/1.1
    > Host: localhost:8695
    > User-Agent: curl/7.68.0
    > Accept: */*
    > Authorization: Bearer eyJh...GBew
    > Content-Length: 902
    > Content-Type: multipart/form-data; boundary=...
    >
    < HTTP/1.1 200 OK
    < Content-Length: 182
    < Content-Type: application/json;charset=UTF-8
    < Date: Wed, 14 Aug 2024 11:13:21 GMT
    < Server: OWL/1.11
    <
    {
      "code": "E0000",
      "file": "/home/foo/tmp/owl/public/foo/bar/test123.txt",
      "md5": "acaa9dd36fe3b6758e91129170910378",
      "size": "490",
      "status": true,
      "uploaded": "2024-08-14T11:13:21Z"
    }

=head2 ERROR CODES

List of NoAPI Suffit API error codes

    API   | HTTP  | DESCRIPTION
   -------+-------+-------------------------------------------------
    E1110   [400]   No file path specified
    E1111   [400]   Incorrect file path
    E1112   [404]   File not found
    E1113   [500]   Can't upload file
    E1114   [500]   Can't upload file: file is lost
    E1115   [400]   File size mismatch
    E1116   [400]   File md5 checksum mismatch
    E1117   [---]   Reserved
    E1118   [---]   Reserved
    E1119   [---]   Reserved

B<*> -- this code will be defined later on the interface side

See also list of common Suffit API error codes in L<WWW::Suffit::API/"ERROR CODES">

=head1 HISTORY

See C<Changes> file

=head1 TO DO

See C<TODO> file

=head1 SEE ALSO

L<Mojolicious>, L<WWW::Suffit>, L<WWW::Suffit::Server>, L<WWW::Suffit::API>

=head1 AUTHOR

Serż Minus (Sergey Lepenkov) L<https://www.serzik.com> E<lt>abalama@cpan.orgE<gt>

=head1 COPYRIGHT

Copyright (C) 1998-2024 D&D Corporation. All Rights Reserved

=head1 LICENSE

This program is free software; you can redistribute it and/or
modify it under the same terms as Perl itself.

See C<LICENSE> file and L<https://dev.perl.org/licenses/>

=cut

use Mojo::Base 'Mojolicious::Controller';

use File::Find qw/find/;
use File::stat;

use Mojo::JSON qw / true false /;
use Mojo::File qw/path/;
use Mojo::Util qw/decode/;
use Mojo::Date;

use WWW::Suffit::Util qw/ md5sum /;
use WWW::Suffit::Const qw/ USERNAME_REGEXP /;

sub file_list {
    my $self = shift;
    my $src = $self->app->documentroot;
    my $root = stat($src);

    my @manifest = ({
        id          => $root->ino,
        pid         => 0,
        filename    => '',
        directory   => '/',
        path        => '/',
        perms       => $root->mode & 07777,
        size        => $root->size,
        mdate       => $root->mtime,
        type        => 'folder',
        absf        => '/',
    });
    find({  follow      => 1,
            follow_skip => 2,
            wanted      => sub
    {
        my $fn = $_;
        my $dir = $File::Find::dir;
        return 0 if $fn =~ /^\./;
        return 0 unless -f $fn or -d $fn;
        my $type = -d $fn ? 'folder' : 'file';
        my $sb = stat($fn);
        my $dirf = path($dir)->to_rel($src)->to_string; # Directory (rel)
           $dirf =~ s/^\.*\/*/\//;
        my $absf = path("/", $dirf, $fn)->to_string; # File path (abs)
        my $relf = path($dirf, $fn)->to_string; # File path (rel)
           $relf =~ s/^\.*\/+//;

        # Out
        push @manifest, {
            id          => $sb->ino,
            pid         => stat($dir)->ino,
            filename    => decode('UTF-8', $fn) // '',
            directory   => decode('UTF-8', $dirf) // '',
            path        => decode('UTF-8', $relf) // '',
            perms       => $sb->mode & 07777,
            size        => $sb->size,
            mdate       => $sb->mtime,
            type        => $type,
            absf        => decode('UTF-8', $absf) // '',
        };
    }}, $src);

    # Correct manifest
    my $unfl = _unflatten(\@manifest);
    my $fl = _flatten($unfl);

    return $self->render(json => {
        status          => true,
        documentroot    => $src,
        manifest        => $fl,
    });
}
sub file_download {
    my $self = shift;
    my $filepath = $self->param('filepath') // '';
    return 1 if $self->_validate_filepath($filepath); # Validate filepath

    # Set paths
    my $path = path($self->app->documentroot, $filepath);

    # Get full file name
    my $fullfilename = $path->to_string;

    # Check file
    return $self->reply->json_error(404 => "E1112: File not found: \"$filepath\"")
        unless (-e $fullfilename and -f $fullfilename and -s $fullfilename);

    # Send file
    return $self->reply->static($filepath)
}
sub file_upload {
    my $self = shift;
    my $filepath = $self->param('filepath') // '';
    return 1 if $self->_validate_filepath($filepath); # Validate filepath

    # Set paths
    my $path_file = path($self->app->documentroot, $filepath);
    my $path_dir = $path_file->dirname->make_path;
    my $path_name = $path_file->to_string;

    # Upload file @fileraw@
    my $fileuploaded = $self->req->upload('fileraw');
    if ($fileuploaded) {
        my $size = $fileuploaded->size;
        my $name = $fileuploaded->filename;
        $fileuploaded->move_to($path_name);
        my $md5  = md5sum($path_name);

        # Check uploaded file
        return $self->reply->json_error(500 => "E1114: Can't upload file \"$filepath\": file is lost")
            unless -e $path_name;

        # Check size
        my $expected_size = $self->param("size") || 0;
        if ($expected_size && $expected_size != $size) {
            $path_file->remove;
            return $self->reply->json_error(400 =>
                sprintf("E1115: File size mismatch: expected=\"%s\"; got=\"%s\"", $expected_size, $size));
        }

        # Check md5sum
        my $expected_md5 = $self->param("md5") // '';
        if (length($expected_md5) && $expected_md5 ne $md5) {
            $path_file->remove;
            return $self->reply->json_error(400 =>
                sprintf("E1116: File md5 checksum mismatch: expected=\"%s\"; got=\"%s\"", $expected_md5, $md5));
        }
    } else {
        return $self->reply->json_error(500 => "E1113: Can't upload file \"$filepath\"");
    }

    # Result
    return $self->reply->json_ok({
        file        => $path_name,
        size        => $self->param("size"),
        md5         => $self->param("md5"),
        uploaded    => Mojo::Date->new(time)->to_datetime, # RFC 3339
    });
}
sub file_remove {
    my $self = shift;
    my $filepath = $self->param('filepath') // '';
    return 1 if $self->_validate_filepath($filepath); # Validate filepath

    # Set paths
    my $path = path($self->app->documentroot, $filepath);

    # Get full file name
    my $fullfilename = $path->to_string;

    # Check file
    return $self->reply->json_error(404 => "E1112: File not found: \"$filepath\"")
        unless (-e $fullfilename and -f $fullfilename and -s $fullfilename);

    # Delete file
    $path->remove;

    # Result
    return $self->reply->json_ok;
}

sub _unflatten {
    my $arr = shift // [];
    my $parent = shift // {id => 0};
    my $tree = shift // [];

    my @children = grep { $_->{pid} == $parent->{id} } @$arr;
    return $tree unless scalar @children;

    if ($parent->{id} == 0) {
        $tree = [@children];
    } else {
        $parent->{items} = [@children];
    }

    foreach my $child (@children) {
        &_unflatten($arr, $child);
    }

    return $tree;
}
sub _flatten {
    my $tree = shift // [];
    my $arr = [];

    foreach my $child (sort { $b->{type} cmp $a->{type} || $a->{filename} cmp $b->{filename} } @$tree) {
        if (exists $child->{items}) {
            my $children = &_flatten($child->{items});
            delete $child->{items};
            push @$arr, $child;
            push @$arr, @$children;
        } else {
            push @$arr, $child
        }
    }

    return $arr;
}
sub _validate_filepath { # Returns true if invalid!
    my $self = shift;
    my $filepath = shift // $self->param('filepath') // '';
    my @test = split /\//, $filepath;
    return $self->reply->json_error(400 => "E1110: No file path specified") unless scalar @test;
    my $status = 1;
    foreach my $d (@test) {
        unless (defined($d) && length($d) && (length($d) <= 64) && $d =~ USERNAME_REGEXP) {
            $status = 0;
            last;
        }
        if (($d =~ /\.{2,}/) || ($d =~ /^[~]/)) {
            $status = 0;
            last;
        }
    }
    return $self->reply->json_error(400 => "E1111: Incorrect file path") unless $status;
    return;
}

1;

__END__


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.