#!/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 . # author: jake [a\ jakes-mail dot top # release: 29, Oct 2023 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 EPERM EBADF ENOSPC EMFILE ENOSYS); use File::Slurper qw(read_text read_binary write_binary); use Mojo::Date; use Getopt::Long; use Carp::Always; use Smart::Comments; use File::Temp qw(tempfile tempdir); use threads; use threads::shared; 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 :shared; 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; } 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."; } 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; } } 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; 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}; } } } 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({ type => $type, mode => $mode, ctime => $times, size => $size, }); $files{$dirs}{$filename}{fn} = $fns{$dirs}{$filename}{fn}; } $files{'/'}{'.'} = shared_clone({ type => $TYPE_DIR, mode => 0755, ctime => time(), size => 4096, fn => undef, }); $files{'/'}{'..'} = shared_clone({ type => $TYPE_DIR, mode => 0755, ctime => time(), size => 4096, fn => undef, }); # ## %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; } 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); } sub e_read { my ($dirs, $file) = get_path_and_file(shift); my ($buf, $off, $_fh) = @_; 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 } } else { my $body = read_binary($files{$dirs}{$file}{fn}); return substr($body, $off, $buf); } } 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}); 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; } 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; $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 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; 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; return res_errno($res, 0); } sub res_errno { my ($res, $buf_len) = @_; if ($res->is_success) { 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 and neocities thinks you've uploaded no files '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} }; } 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; 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; 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, mountopts => "allow_other", 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, );