neocitiesfs/neocitiesfs.pl

539 lines
14 KiB
Perl
Raw Normal View History

2023-10-29 15:39:27 -07:00
#!/usr/bin/perl -w
# This program is free software: you can redistribute it and/or modify it under the terms of
# the GNU General Public License as published by the Free Software Foundation, either version
# 3 of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
# See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with this program.
# If not, see <https://www.gnu.org/licenses/>.
# author: jake [a\ jakes-mail dot top
# release: 29, Oct 2023
2023-10-29 15:39:27 -07:00
use strict;
use warnings;
use 5.010;
use Mojo::UserAgent;
use JSON;
use Fuse qw(fuse_get_context);
use POSIX qw(ENOENT EISDIR EINVAL EEXIST ENOTEMPTY EACCES EFBIG
2023-10-29 15:39:27 -07:00
EPERM EBADF ENOSPC EMFILE ENOSYS);
use File::Slurper qw(read_text read_binary write_binary);
2023-10-29 15:39:27 -07:00
use Mojo::Date;
use Getopt::Long;
use Carp::Always;
use Smart::Comments;
use File::Temp qw(tempfile tempdir);
use threads;
use threads::shared;
2023-10-29 15:39:27 -07:00
my $user;
my $pass;
my $api;
my $mountpoint;
my $config;
GetOptions ("user=s" => \$user,
"pass=s" => \$pass,
2023-10-29 15:39:27 -07:00
"api=s" => \$api,
"mountpoint=s" => \$mountpoint,
"config=s" => \$config,
)
or die("Error in command line arguments\n");
{
my $death_string = '';
if (! ($pass or $api) ) {
if ($config) {
if (-e $config and -r $config) {
my $config_ref = from_json(read_text($config));
if (exists $config_ref->{user}) {
$user = $config_ref->{user} if not $user;
}
2023-10-29 15:39:27 -07:00
if (exists $config_ref->{pass}) {
$pass = $config_ref->{pass} if not $pass;
}
if (exists $config_ref->{api}) {
$api = $config_ref->{api} if not $api;
}
}
else {
$death_string .= "$config doesn't exist or is readable\n";
}
}
else {
$death_string .= "./neocitiesfs.pl <--user 'username'> <--pass 'pass' || --api 'api key'> or only --config\n";
}
}
if (! $user) {
$death_string .= "no --user\n";
}
if (! ($pass or $api) ) {
$death_string .= "no --pass or --api\n";
}
if (! $mountpoint) {
$death_string .= "no --mountpoint\n";
}
die $death_string if $death_string;
}
my %files :shared;
2023-10-29 15:39:27 -07:00
my $suppress_list_update = 0;
# for neocities, it can only be dir or file
my $TYPE_DIR = 0040;
my $TYPE_FILE = 0100;
my $tmpdir = tempdir();
END {
for my $dir (keys %files) {
for my $file (keys %{ $files{$dir} }) {
unlink $files{$dir}{$file}{fn} if $files{$dir}{$file}{fn};
}
}
rmdir $tmpdir;
}
2023-10-29 15:39:27 -07:00
die unless try_auth_info();
get_listing_from_neocities();
sub try_auth_info {
my $ua = Mojo::UserAgent->new;
my ($tx, $res);
if ($pass) {
$tx = $ua->get("https://$user:$pass\@neocities.org/api/info");
$res = $tx->res;
}
else {
my $tx = $ua->build_tx(GET => 'https://neocities.org/api/info');
2023-10-29 15:39:27 -07:00
$tx->req->headers->authorization("Bearer $api");
$res = $ua->start($tx)->res;
}
if ($res->is_error) {
die "auth pass or api seems to be incorrect.";
}
else {
# get api key and use that over username + password
# checking if API key is valid is wayyyyy quicker than
# checking if user 'username' exists and hashing the supplied
# password then comparing hashes.
if (! $api) {
my $tx = $ua->get("https://$user:$pass\@neocities.org/api/key");
my $res = $tx->res;
my $body = from_json($res->body);
$api = $body->{api_key};
undef $pass;
}
}
2023-10-29 15:39:27 -07:00
return 1;
}
sub get_listing_from_neocities {
return if $suppress_list_update;
my $ua = Mojo::UserAgent->new;
$ua = $ua->max_redirects(1); # just in case
my $tx = $ua->build_tx(GET => 'https://neocities.org/api/list');
$tx->req->headers->authorization("Bearer $api");
my $res = $ua->start($tx)->res;
2023-10-29 15:39:27 -07:00
if ($res->is_success) {
my $known_files = from_json($res->body);
update_known_files($known_files);
return 0;
}
else {
return res_errno($res,0);
}
}
sub update_known_files {
my ($known_files) = @_;
my %fns;
for my $dirs (keys %files) {
for my $file (keys %{ $files{$dirs} }) {
$fns{$dirs}{$file}{fn} = undef;
# autovivication on shared variables can fatally terminate this program
if (exists $files{$dirs} and exists $files{$dirs}
and exists $files{$dirs}{$file} and exists $files{$dirs}{$file}{fn})
{
$fns{$dirs}{$file}{fn} = $files{$dirs}{$file}{fn};
}
}
}
2023-10-29 15:39:27 -07:00
undef %files;
for my $e (@{ $known_files->{files} }) {
my ($dirs, $filename) = get_path_and_file($e->{path});
my $date = Mojo::Date->new($e->{updated_at});
my $times = $date->epoch;
my $size = exists $e->{size} ? $e->{size} : 4096;
my $type;
my $mode;
if ($e->{is_directory}) {
$type = $TYPE_DIR;
$mode = 0755;
$files{ "/$e->{path}" }{'.'} = {
type => $TYPE_DIR,
mode => 0755,
ctime => time(),
size => 4096,
};
$files{ "/$e->{path}" }{'..'} = {
type => $TYPE_DIR,
mode => 0755,
ctime => time(),
size => 4096,
};
} else {
$type = $TYPE_FILE;
$mode = 0644;
}
$files{$dirs} //= &share({}); # mmm, tasty nested shared memory
$files{$dirs}{$filename} = shared_clone({
2023-10-29 15:39:27 -07:00
type => $type,
mode => $mode,
ctime => $times,
size => $size,
});
$files{$dirs}{$filename}{fn} = $fns{$dirs}{$filename}{fn};
2023-10-29 15:39:27 -07:00
}
$files{'/'}{'.'} = shared_clone({
2023-10-29 15:39:27 -07:00
type => $TYPE_DIR,
mode => 0755,
ctime => time(),
size => 4096,
fn => undef,
});
$files{'/'}{'..'} = shared_clone({
2023-10-29 15:39:27 -07:00
type => $TYPE_DIR,
mode => 0755,
ctime => time(),
size => 4096,
fn => undef,
});
2023-10-29 15:39:27 -07:00
# ## %files
}
sub get_path_and_file {
my $path = shift;
my @paths = split '/', $path;
my $dirs = '';
for (0..($#paths-1)) {
$dirs .= "/$paths[$_]"
}
if ((substr $dirs, 0, 2) eq '//') {
substr $dirs, 0, 2, '/'; # '//something' -> '/something'
}
my $filename = $paths[-1];
if ( ! $dirs ) {
$dirs = '/';
}
if ( ! $filename ) {
$filename = '.';
}
return $dirs, $filename;
}
sub e_getattr {
my ($dirs, $file) = get_path_and_file(shift);
return -ENOENT() unless exists($files{$dirs}{$file});
my $size = $files{$dirs}{$file}->{size} if exists $files{$dirs}{$file}->{size};
my ($modes) = ($files{$dirs}{$file}->{type}<<9) + $files{$dirs}{$file}->{mode};
my ($dev, $ino, $rdev, $blocks, $gid, $uid, $nlink, $blksize) = (0,0,0,1,$<,$<,1,1024);
my ($atime, $ctime, $mtime);
$atime = $ctime = $mtime = $files{$dirs}{$file}->{ctime};
return ($dev,$ino,$modes,$nlink,$uid,$gid,$rdev,$size,$atime,$mtime,$ctime,$blksize,$blocks);
}
sub e_getdir {
my ($dirs) = @_;
return (keys %{ $files{$dirs} } ),0;
}
sub e_open {
# VFS sanity check; it keeps all the necessary state, not much to do here.
my ($dirs, $file) = get_path_and_file(shift);
my ($flags, $fileinfo) = @_;
return -ENOENT() unless exists($files{$dirs}{$file});
return -EISDIR() if $files{$dirs}{$file}{type} & 0040;
return 0;
2023-10-29 15:39:27 -07:00
}
sub e_create {
my $path = shift;
my ($dirs, $file) = get_path_and_file($path);
return -EEXIST if exists($files{$dirs}{$file});
my $res = write_to_neocities($dirs, $file, '');
return res_errno($res, 0);
}
2023-10-29 15:39:27 -07:00
sub e_read {
my ($dirs, $file) = get_path_and_file(shift);
my ($buf, $off, $_fh) = @_;
2023-10-29 15:39:27 -07:00
return -ENOENT() unless exists($files{$dirs}{$file});
if (! $files{$dirs}{$file}{fn}) {
my $ua = Mojo::UserAgent->new;
$ua = $ua->max_redirects(1); # for some reason neocities redirects .html files to not-.html files.
my $res = $ua->get("https://$user.neocities.org/$dirs/$file")->result;
if ($res->is_success) {
# filehandles CANNOT be shared between threads
(undef, $files{$dirs}{$file}{fn}) = tempfile('neocitiesfs_XXXXXXX', DIR => $tmpdir, UNLINK => 0);
my $fn = $files{$dirs}{$file}{fn};
# this is what write_binary() does but I had issues with it
open my $fh, '>:raw', $fn;
print $fh $res->body;
close $fh;
return substr($res->body,$off,$buf);
}
else {
return -77; # EBADFD, file descrpitor in bad state
}
2023-10-29 15:39:27 -07:00
}
else {
my $body = read_binary($files{$dirs}{$file}{fn});
return substr($body, $off, $buf);
2023-10-29 15:39:27 -07:00
}
}
sub e_statfs { return 255, 1, 1, 1, 1, 2 }
sub e_write {
my ($dirs, $file) = get_path_and_file(shift);
my ($buf, $off, $_fh) = @_;
2023-10-29 15:39:27 -07:00
return -ENOENT() unless exists($files{$dirs}{$file});
if (! $files{$dirs}{$file}{fn}) {
# filehandles CANNOT be shared between threads
(undef, $files{$dirs}{$file}{fn}) = tempfile('neocitiesfs_XXXXXXX', DIR => $tmpdir);
}
open my $fh, '>>', $files{$dirs}{$file}{fn};
$fh->autoflush( 1 ); # perl doesnt 'print line' until it sees "\n" normally
seek $fh, $off, 0 if $off;
print $fh $buf;
close $fh;
$files{$dirs}{$file}{modified} = 1;
return length $buf;
}
sub e_flush {
my ($path, $_fh) = @_;
my ($dirs, $file) = get_path_and_file($path);
if ($files{$dirs}{$file}{modified} and $files{$dirs}{$file}{modified} == 1) {
my $fn = $files{$dirs}{$file}{fn};
my $res = write_to_neocities($dirs, $file, $fn, 1);
my $errno = res_errno($res, 0);
if ($errno == 0) {
$files{$dirs}{$file}{modified} = 0; # synchronized so no longer modified
}
return $errno;
}
else {
return 0;
}
}
sub e_not_implimented { return -ENOSYS; }
sub e_lie_implimented { return 0; }
sub e_truncate {
my ($path, $length) = @_;
my ($dirs, $file) = get_path_and_file($path);
return -ENOENT if ! exists $files{$dirs}{$file};
if (! $files{$dirs}{$file}{fn}) {
e_read($path);
}
open my $fh, '>', $files{$dirs}{$file}{fn};
truncate $fh, $length;
$files{$dirs}{$file}{modified} = 1;
close $fh;
my $res = e_flush($path);
return $res;
2023-10-29 15:39:27 -07:00
}
sub e_mknod {
my ($dirs, $file) = get_path_and_file(shift);
return -EEXIST if exists $files{$dirs}{$file};
my $res = write_to_neocities($dirs, $file, '');
return res_errno($res, 0);
}
sub e_unlink {
my ($dirs, $file) = get_path_and_file(shift);
my $ua = Mojo::UserAgent->new;
my $tx = $ua->build_tx(POST => 'https://neocities.org/api/delete', => {Accept => '*/*'} => form =>
{'filenames[]' => [ "$dirs/$file" ]});
$tx->req->headers->authorization("Bearer $api");
my $res = $ua->start($tx)->res;
2023-10-29 15:39:27 -07:00
$suppress_list_update = 1;
my $errno = res_errno($res, 0);
if ($errno == 0) {
delete $files{$dirs}{$file};
}
$suppress_list_update = 0;
return $errno;
}
sub e_mkdir {
# making it 'locally' as neocities auto-mkdir when user puts a file
2023-10-29 15:39:27 -07:00
my $path = shift;
my ($dirs, $file) = get_path_and_file($path);
return -EEXIST if exists $files{$dirs}{$file};
$files{$dirs}{$file} = {
type => $TYPE_DIR,
mode => 0755,
ctime => time(),
size => 4096,
};
$files{$path}{'.'} = {
type => $TYPE_DIR,
mode => 0755,
ctime => time(),
size => 4096,
};
$files{$path}{'..'} = {
type => $TYPE_DIR,
mode => 0755,
ctime => time(),
size => 4096,
};
return 0;
}
# neocities '/api/delete' doesn't care about files under the directory tree
# decided to keep this 'feature' to reduce calls to /api/
sub e_rmdir {
my $path = shift;
return -ENOENT if not exists $files{$path};
my $ua = Mojo::UserAgent->new;
my $tx = $ua->build_tx(POST => 'https://neocities.org/api/delete', => {Accept => '*/*'} => form =>
{'filenames[]' => [ "$path" ]});
$tx->req->headers->authorization("Bearer $api");
my $res = $ua->start($tx)->res;
2023-10-29 15:39:27 -07:00
return res_errno($res, 0);
}
sub e_rename {
my ($old_path, $new_path) = @_;
my ($old_dirs, $old_file) = get_path_and_file($old_path);
my ($new_dirs, $new_file) = get_path_and_file($new_path);
return -ENOENT if not exists $files{$old_dirs}{$old_file};
return -EEXIST if exists $files{$new_dirs}{$new_file};
my $ua = Mojo::UserAgent->new;
my $tx = $ua->build_tx(POST => 'https://neocities.org/api/rename', => {Accept => '*/*'} => form =>
{ path => $old_path, new_path => $new_path });
$tx->req->headers->authorization("Bearer $api");
my $res = $ua->start($tx)->res;
2023-10-29 15:39:27 -07:00
return res_errno($res, 0);
}
sub res_errno {
my ($res, $buf_len) = @_;
if ($res->is_success) {
get_listing_from_neocities();
2023-10-29 15:39:27 -07:00
return $buf_len;
}
elsif ($res->code == 400) {
my $body = from_json($res->body);
my %error_codes = (
'invalid_file_type' => -124, ## -EMEDIUMTYPE -- meant to convey that user can't upload this kind of file
'missing_files' => -ENOENT, # when uploading and neocities thinks you've uploaded no files
2023-10-29 15:39:27 -07:00
'too_large' => -EFBIG,
'too_many_files' => -EMFILE, #-ENOSPC,
'directory_exists' => -EEXIST,
'missing_arguments' => -EINVAL,
'bad_path' => -EINVAL,
'bad_new_path' => -EINVAL,
'missing_file' => -EBADF, # 'you must provide files to upload'
'rename_error' => -EINVAL,
'missing_filenames' => -EINVAL,
'bad_filename' => -EINVAL,
'cannot_delete_site_directory' => -EPERM,
'cannot_delete_index' => -EPERM,
'email_not_validated' => -56, # EBADRQC # invaild requuest code ¯\_(ツ)_/¯
'invalid_auth' => -EACCES,
'not_found' => -ENOSYS, # calls to /api/ that doesn't exist (not that user should get this error message)
);
return $error_codes{ $body->{error_type} };
2023-10-29 15:39:27 -07:00
}
else {
# some kind of error, maybe related to internet?
return -EINVAL;
}
}
# this returns mojo's 'res' thing
sub write_to_neocities {
my ($dirs, $file, $buffer, $is_buf_fn) = @_;
defined $is_buf_fn or $is_buf_fn = 0;
2023-10-29 15:39:27 -07:00
my $ua = Mojo::UserAgent->new();
my $asset;
if (! $is_buf_fn) {
$asset = Mojo::Asset::Memory->new->add_chunk($buffer);
}
else {
$asset = Mojo::Asset::File->new(path => $buffer);
}
my $tx = $ua->build_tx(POST => 'https://neocities.org/api/upload' =>
{Accept => '*/*'} => form => {"$dirs/$file" => { file => $asset } });
$tx->req->headers->authorization("Bearer $api");
my $res = $ua->start($tx)->res;
undef $asset;
2023-10-29 15:39:27 -07:00
return $res;
}
2023-10-29 15:39:27 -07:00
# If you run the script directly, it will run fusermount, which will in turn
# re-run this script. Hence the funky semantics.
#my ($mountpoint) = "";
#$mountpoint = shift(@ARGV) if @ARGV;
Fuse::main(
mountpoint=>$mountpoint,
2023-10-29 18:17:14 -07:00
mountopts => "allow_other",
2023-10-29 15:39:27 -07:00
getattr=>"main::e_getattr",
getdir =>"main::e_getdir",
open =>"main::e_open",
statfs =>"main::e_statfs",
read =>"main::e_read",
write =>"main::e_write",
mknod =>"main::e_mknod",
unlink =>"main::e_unlink",
mkdir =>"main::e_mkdir",
rmdir =>"main::e_rmdir",
rename =>"main::e_rename",
create =>"main::e_create",
flush =>"main::e_flush",
truncate => "main::e_truncate",
utime =>"main::e_not_implimented",
chown =>"main::e_not_implimented",
chmod =>"main::e_not_implimented",
threaded=>1,
debug=>0,
2023-10-29 15:39:27 -07:00
);