commit e091da0412d1ff47c2b55ea33cbce23ef4281507 Author: jake Date: Sun Oct 29 18:39:27 2023 -0400 GIT THIS diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d344ba6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +config.json diff --git a/config.json.sample b/config.json.sample new file mode 100644 index 0000000..3bbc0f9 --- /dev/null +++ b/config.json.sample @@ -0,0 +1,5 @@ +{ + "user":"username", + "pass":"password; or delete this line and api key will be used", + "api":"aaaaaaaaaaaaaaaaaaaaaaaaaaa" +} diff --git a/neocitiesfs.pl b/neocitiesfs.pl new file mode 100755 index 0000000..3a4b07f --- /dev/null +++ b/neocitiesfs.pl @@ -0,0 +1,456 @@ +#!/usr/bin/perl -w +use strict; +use warnings; +use 5.010; +use Mojo::UserAgent; +use JSON; +use Data::Dumper; +use Fuse qw(fuse_get_context); +use POSIX qw(ENOENT EISDIR EINVAL EEXIST ENOTEMPTY EACCES EFBIG + EPERM EBADF ENOSPC EMFILE ENOSYS); +use Smart::Comments; +use File::Slurper qw(read_text); +use Mojo::Date; +use Carp::Always; + +use Getopt::Long; +my $user; +my $pass; +my $api; +my $mountpoint; +my $config; +GetOptions ("user=s" => \$user, + "pass=s" => \$pass, + "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; + } + 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; +my $suppress_list_update = 0; + +# for neocities, it can only be dir or file +my $TYPE_DIR = 0040; +my $TYPE_FILE = 0100; + + +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'); + $tx->req->headers->authorization("Bearer $api"); + $res = $ua->start($tx)->res; + } + if ($res->is_error) { + die "auth pass or api seems to be incorrect."; + } + 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, $res); + + if ($pass) { + $tx = $ua->get("https://$user:$pass\@neocities.org/api/list"); + $res = $tx->res; + } + else { + my $tx = $ua->build_tx(GET => 'https://neocities.org/api/list'); + $tx->req->headers->authorization("Bearer $api"); + $res = $ua->start($tx)->res; + } + + 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) = @_; + 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}{$filename} = { + type => $type, + mode => $mode, + ctime => $times, + size => $size, + }; + } + $files{'/'}{'.'} = { + type => $TYPE_DIR, + mode => 0755, + ctime => time(), + size => 4096, + }; + $files{'/'}{'..'} = { + type => $TYPE_DIR, + mode => 0755, + ctime => time(), + size => 4096, + }; + # ## %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; + + my $fh = [ rand() ]; + + return (0, $fh); +} + +sub e_read { + my ($dirs, $file) = get_path_and_file(shift); + my ($buf, $off, $fh) = @_; + return -ENOENT() unless exists($files{$dirs}{$file}); + + 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) { + return substr($res->body,$off,$buf); + } + else { + return -77; # EBADFD, file descrpitor in bad state + } + + return -EINVAL() if $off > length($files{$dirs}{$file}->{cont}); + return 0 if $off == length($files{$dirs}{$file}->{cont}); +} + +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) = @_; + return -ENOENT() unless exists($files{$dirs}{$file}); + my $res = write_to_neocities($dirs, $file, $buf); + return res_errno($res, length($buf)); +} + +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, $res); + + if ($pass) { + $tx = $ua->post("https://$user:$pass\@neocities.org/api/delete", => {Accept => '*/*'} => form => + {'filenames[]' => [ "$dirs/$file" ]}); + my $res = $tx->res; + } + else { + 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; + } + + $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 { + # so, neocities API doesn't exactly have a '/api/create_dir/' + # BUT does create a dir if you upload a file that is in a dir. + # :) +# my ($dirs, $file) = get_path_and_file(shift); +# return -EEXIST if exists $files{$dirs}{$file}; +# my $numb = int(rand(99999999)) . 'mkdir_hopefully_no_collsions.html'; +# +# $suppress_list_update = 1; +# my $res = e_mknod("$dirs/$file/$numb"); +# +# $suppress_list_update = 0; +# return res_errno($res,0) if $res != 0; + +# return e_unlink("$dirs/$file/$numb"); + + # or I could just create a directory 'locally' since it is likely the user will put something in it + # (also reduces calls to /api/) + 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, + }; + # ## %files + 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}; + # commented out for now; causes too many unlink() and get_listing_from_neocities() which just by themselves take a while to complete + #if (not scalar keys %{ $files{$path} } == 2) { # '.' and '..' + # return -ENOTEMPTY; + #} + + my $ua = Mojo::UserAgent->new; + my ($tx, $res); + if ($pass) { + $tx = $ua->post("https://$user:$pass\@neocities.org/api/delete", => {Accept => '*/*'} => form => + {'filenames[]' => [ "$path" ]}); + $res = $tx->res; + } + else { + $tx = $ua->build_tx(POST => 'https://neocities.org/api/delete', => {Accept => '*/*'} => form => + {'filenames[]' => [ "$path" ]}); + $tx->req->headers->authorization("Bearer $api"); + $res = $ua->start($tx)->res; + } + 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, $res); + if ($pass) { + $tx = $ua->post("https://$user:$pass\@neocities.org/api/rename", => {Accept => '*/*'} => form => + { path => $old_path, new_path => $new_path }); + $res = $tx->res; + } + else { + $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"); + $res = $ua->start($tx)->res; + } + return res_errno($res, 0); +} + +sub res_errno { + my ($res, $buf_len) = @_; + if ($res->is_success) { + my $res = get_listing_from_neocities(); + 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, should work normally with mv + '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) + ) + } + 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) = @_; + + my $ua = Mojo::UserAgent->new(); + my $asset = Mojo::Asset::Memory->new->add_chunk($buffer); + my ($tx, $res); + if ($pass) { + $tx = $ua->post("https://$user:$pass\@neocities.org/api/upload" => + {Accept => '*/*'} => form => {"$dirs/$file" => { file => $asset } }); + $res = $tx->res; + } + else { + $tx = $ua->build_tx(POST => 'https://neocities.org/api/upload' => + {Accept => '*/*'} => form => {"$dirs/$file" => { file => $asset } }); + $tx->req->headers->authorization("Bearer $api"); + $res = $ua->start($tx)->res; + } + return $res; +} + +# 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, + 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", + threaded=>0 +);