commit 8f25cd06e3e15c1006e8e00452f26ad2c74212b0 Author: Benjamin Date: Tue Apr 7 16:26:22 2026 -0400 Initial commit. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3267ec5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Benjamin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c9f08b5 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# cloudvista +Web based cloud storage manager diff --git a/cloudvista.cgi b/cloudvista.cgi new file mode 100755 index 0000000..244b252 --- /dev/null +++ b/cloudvista.cgi @@ -0,0 +1,706 @@ +#!/usr/bin/perl +############################################################## +# CloudVista — cloud storage manager +# Copyright © RoyR 2026 All rights reserved. +# Distributed under the terms of the MIT License. +# +# perl 5.8+, core modules + CGI +# chmod 755, set $STORAGE_ROOT below +############################################################## + +use strict; +use warnings; +use CGI (); +use CGI::Cookie; +use Digest::MD5 qw(md5_hex); +use File::Basename qw(basename); +use POSIX qw(strftime); + +$| = 1; # unbuffered — never silently drop bytes + +############################################################## +# CONFIGURATION +############################################################## +# Note: STORAGE_ROOT should not be in the document root, unless +# you know what you are doing. +my $STORAGE_ROOT = "/var/www/storage"; # must be writable by httpd user +my $MAX_UPLOAD = 1073741824; # 1 GB +my $SCRIPT_NAME = $ENV{SCRIPT_NAME} || "/cgi-bin/cloudvista.cgi"; +my $COOKIE_DAYS = 30; +############################################################## + +$CGI::POST_MAX = $MAX_UPLOAD; +$CGI::DISABLE_UPLOADS = 0; +# ---- bail out loud if storage root is missing ---- +unless (-d $STORAGE_ROOT) { + print "Content-Type: text/plain\r\n\r\n"; + print "ERROR: \$STORAGE_ROOT ($STORAGE_ROOT) does not exist.\n"; + print "Fix: mkdir -p $STORAGE_ROOT && chown $STORAGE_ROOT\n"; + exit; +} + +my $q = CGI->new; +my $action = $q->param('action') || ''; +# ---- read cookies ---- +my %cookies = CGI::Cookie->fetch; +my $c_uhash = $cookies{uhash} ? $cookies{uhash}->value : ''; +my $c_phash = $cookies{phash} ? $cookies{phash}->value : ''; + +############################################################## +# HEADER HELPERS — raw prints, no CGI.pm header()/redirect() +############################################################## + +sub send_redirect { + my ($url, @cookies) = @_; + print "Location: $url\r\n"; + for my $c (@cookies) { print "Set-Cookie: ", $c->as_string, "\r\n"; + } + print "\r\n"; + exit; +} + +sub send_page_header { + my @cookies = @_; + print "Content-Type: text/html; charset=UTF-8\r\n"; + for my $c (@cookies) { print "Set-Cookie: ", $c->as_string, "\r\n"; } + print "\r\n"; +} + +sub make_login_cookies { + my ($uh, $ph) = @_; + my $exp = "+${COOKIE_DAYS}d"; + return ( + CGI::Cookie->new(-name=>'uhash',-value=>$uh,-expires=>$exp,-path=>'/',-httponly=>1), + CGI::Cookie->new(-name=>'phash',-value=>$ph,-expires=>$exp,-path=>'/',-httponly=>1), + ); +} + +sub make_expired_cookies { + return ( + CGI::Cookie->new(-name=>'uhash',-value=>'',-expires=>'-1d',-path=>'/',-httponly=>1), + CGI::Cookie->new(-name=>'phash',-value=>'',-expires=>'-1d',-path=>'/',-httponly=>1), + ); +} + +############################################################## +# MISC HELPERS +############################################################## + +sub fmt_size { + my $b = shift || 0; + return sprintf("%.1f GB", $b/1073741824) if $b >= 1073741824; + return sprintf("%.1f MB", $b/1048576) if $b >= 1048576; + return sprintf("%.1f KB", $b/1024) if $b >= 1024; + return "$b B"; +} + +sub dir_size { + my $dir = shift; + my $tot = 0; + if (opendir my $dh, $dir) { + while (my $f = readdir $dh) { + next if $f eq '.' +|| $f eq '..'; + $tot += (stat "$dir/$f")[7]||0 if -f "$dir/$f"; + } + closedir $dh; + } + return $tot; +} + +sub safe_name { + my $n = basename(shift || ''); + $n =~ s/[^\w.\-]/_/g; + $n =~ s/^\./dot_/; + return length($n) ? $n : 'file'; +} + +sub validate_session { + my ($uh, $ph) = @_; + return undef unless $uh =~ /^[0-9a-f]{32}$/ && $ph =~ /^[0-9a-f]{32}$/; + my $dir = "$STORAGE_ROOT/$uh/$ph"; + return -d $dir ? +$dir : undef; +} + +sub esc { + my $s = shift || ''; + $s =~ s/&/&/g; + $s =~ s//>/g; + $s =~ s/"/"/g; + return $s; +} + +############################################################## +# HTML CHROME +############################################################## + +my $CSS = <<'CSS'; +:root{--bg:#0d1117;--panel:#161b22;--border:#30363d;--accent:#58a6ff; + --green:#3fb950;--red:#f85149;--text:#c9d1d9;--muted:#8b949e; + --r:6px;--mono:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace} +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} +html,body{background:var(--bg);color:var(--text);font-family:var(--mono); + font-size:14px;min-height:100vh} +a{color:var(--accent);text-decoration:none} +a:hover{text-decoration:underline} +.shell{max-width:860px;margin:0 auto;padding:24px 16px 48px} +.topbar{display:flex;align-items:center;justify-content:space-between; + border-bottom:1px solid var(--border);padding-bottom:14px;margin-bottom:24px} +.topbar .logo{font-size:18px;font-weight:700;color:var(--accent);letter-spacing:1px} +.topbar .logo span{color:var(--muted);font-weight:400} +.topbar .nav a{margin-left:18px;color:var(--muted);font-size:13px} +.topbar .nav a:hover{color:var(--text)} +.card{background:var(--panel);border:1px solid var(--border); +border-radius:var(--r);padding:24px 28px;margin-bottom:20px} +.card h2{font-size:14px;color:var(--muted);text-transform:uppercase; + letter-spacing:1px;margin-bottom:18px;padding-bottom:10px; + border-bottom:1px solid var(--border)} +label{display:block;color:var(--muted);font-size:12px;text-transform:uppercase; + letter-spacing:.8px;margin-bottom:5px;margin-top:14px} +input[type=text],input[type=password]{width:100%;background:var(--bg); + border:1px solid var(--border);border-radius:var(--r);color:var(--text); + font-family:var(--mono);font-size:14px;padding:8px 12px;outline:none} +input[type=text]:focus,input[type=password]:focus{border-color:var(--accent)} +input[type=file]{width:100%;background:var(--bg);border:1px dashed var(--border); + border-radius:var(--r);color:var(--muted);font-family:var(--mono); + font-size:13px;padding:10px 12px;cursor:pointer} +input[type=file]:hover{border-color:var(--accent)} +.btn{display:inline-block;margin-top:16px;padding:8px 20px;border-radius:var(--r); +border:none;font-family:var(--mono);font-size:13px;font-weight:600;cursor:pointer} +.btn:hover{opacity:.82} +.btn-primary{background:var(--accent);color:#0d1117} +.btn-success{background:var(--green);color:#0d1117} +table{width:100%;border-collapse:collapse} +thead tr{border-bottom:2px solid var(--border)} +th{text-align:left;color:var(--muted);font-size:11px;text-transform:uppercase; + letter-spacing:.8px;padding:0 8px 10px} +td{padding:9px 8px;border-bottom:1px solid var(--border);vertical-align:middle} +tr:last-child td{border-bottom:none} +tr:hover td{background:rgba(88,166,255,.04)} +.fname{color:var(--text);font-size:13px;word-break:break-all} +.fsize,.fdate{color:var(--muted);white-space:nowrap} +.fdate{font-size:12px} +.factions{white-space:nowrap;text-align:right} +.factions a{margin-left:12px;font-size:12px} +.dl{color:var(--accent)}.del{color:var(--red)} +.alert{border-radius:var(--r);padding:10px 16px;margin-bottom:18px; + font-size:13px;border-left:3px solid} +.alert-err{background:rgba(248,81,73,.1);border-color:var(--red);color:var(--red)} +.alert-ok{background:rgba(63,185,80,.1);border-color:var(--green);color:var(--green)} +.bar-bg{background:var(--border);border-radius:3px;height:6px; + margin-top:8px;overflow:hidden} +.bar-fg{height:100%;border-radius:3px;background:var(--accent)} +.usage{font-size:12px;color:var(--muted);margin-top:5px} +.login-wrap{display:flex;align-items:center;justify-content:center;min-height:90vh} +.login-box{width:100%;max-width:380px} +.logo-big{text-align:center;font-size:26px;font-weight:700;color:var(--accent); +margin-bottom:28px;letter-spacing:2px} +.logo-big span{color:var(--muted);font-weight:400} +.tabs{display:flex;border-bottom:1px solid var(--border);margin-bottom:20px} +.tab{padding:8px 20px;font-size:13px;color:var(--muted); + border-bottom:2px solid transparent;margin-bottom:-1px;text-decoration:none} +.tab.active{color:var(--accent);border-bottom-color:var(--accent)} +.empty{text-align:center;padding:40px 0;color:var(--muted);font-size:13px} +footer{margin-top:40px;text-align:center;color:var(--muted);font-size:11px} +CSS + +sub html_open { + my $t = esc(shift || 'CloudVista'); + return "\n\n\n" + . +"\n" + . +"\n" + . +"$t\n" + . "\n" + . +"\n\n"; +} + +sub html_close { + return "
CloudVista © 2026 RoyR
\n\n\n"; +} + +############################################################## +# ROUTING +############################################################## + +# ---- DOWNLOAD (raw binary response, must come first) ---- +if ($action eq 'download') { + my $ud = validate_session($c_uhash, $c_phash); + my $file = safe_name($q->param('file') || ''); + my $path = ($ud && $file) ? "$ud/$file" : ''; + unless ($path && -f $path) { + send_redirect($SCRIPT_NAME); + } + + my $size = (stat $path)[7]; + (my $disp = $file) =~ s/[^\w.\-]/_/g; + + binmode STDOUT; + print "Content-Type: application/octet-stream\r\n"; + print "Content-Length: $size\r\n"; + print "Content-Disposition: attachment; filename=\"$disp\"\r\n"; + print "\r\n"; + open my $fh, '<', $path or exit; + binmode $fh; + my $buf; + print $buf while read $fh, $buf, 65536; + close $fh; + exit; +} + +# ---- LOGOUT ---- +if ($action eq 'logout') { + send_redirect($SCRIPT_NAME, make_expired_cookies()); +} + +# ---- LOGIN ---- +if ($action eq 'login') { + my $user = $q->param('username') || ''; + my $pass = $q->param('password') || ''; + + if ($user ne '' && $pass ne '') { + my $uh = md5_hex($user); + my $ph = md5_hex($pass); + if (-d "$STORAGE_ROOT/$uh/$ph") { + send_redirect($SCRIPT_NAME, make_login_cookies($uh, $ph)); + } + } + send_page_header(); + print html_open("CloudVista"); + print_login_form('invalid credentials.', 'login'); + print html_close(); + exit; +} + +# ---- REGISTER ---- +if ($action eq 'register') { + my $user = $q->param('username') || ''; + my $pass = $q->param('password') || ''; + my $confirm = $q->param('confirm') || ''; + my $err = ''; + + if ($user eq '' || $pass eq '') { $err = 'all fields required.'; + } + elsif (length($user) < 2) { $err = 'username min 2 chars.'; + } + elsif (length($pass) < 4) { $err = 'password min 4 chars.'; + } + elsif ($pass ne $confirm) { $err = 'passwords do not match.'; + } + else { + my $uh = md5_hex($user); + my $ph = md5_hex($pass); + my $udir = "$STORAGE_ROOT/$uh"; + my $pdir = "$udir/$ph"; + if (-d $udir) { $err = 'username already taken.'; + } + elsif (!mkdir $udir, 0750) { $err = 'server error (mkdir).'; + } + elsif (!mkdir $pdir, 0750) { $err = 'server error (mkdir).'; + } + else { + send_redirect($SCRIPT_NAME, make_login_cookies($uh, $ph)); + } + } + + send_page_header(); + print html_open("CloudVista"); + print_login_form($err, 'register'); + print html_close(); + exit; +} + +# ---- SESSION CHECK ---- +my $userdir = validate_session($c_uhash, $c_phash); + +unless ($userdir) { + send_page_header(); + print html_open("CloudVista"); + print_login_form('', 'login'); + print html_close(); + exit; +} + +my $upload_msg = ''; +my $upload_cls = 'alert-ok'; + + +# ---- CHANGE PASSWORD ---- +if ($action eq 'changepass') { + my $old_pass = $q->param('current_password') || ''; + my $new_pass = $q->param('new_password') || ''; + my $confirm = $q->param('confirm_password') || ''; + my $err = ''; + + # 1. Check if the current password provided is correct + if (md5_hex($old_pass) ne $c_phash) { + $err = 'current password incorrect.'; + } + # 2. Basic validation + elsif ($new_pass eq '' || $confirm eq '') { + $err = 'all fields required.'; + } elsif (length($new_pass) < 4) { + $err = 'new password must be at least 4 chars.'; + } elsif ($new_pass ne $confirm) { + $err = 'new passwords do not match.'; + } else { + # 3. Perform the move + my $new_ph = md5_hex($new_pass); + my $new_dir = "$STORAGE_ROOT/$c_uhash/$new_ph"; + + if (-d $new_dir) { + $err = 'new password is same as current.'; + } elsif (rename($userdir, $new_dir)) { + # Success: redirect with new cookies + send_redirect($SCRIPT_NAME, make_login_cookies($c_uhash, $new_ph)); + exit; + } else { + $err = "system error: $!"; + } + } + + $upload_msg = $err; + $upload_cls = 'alert-err'; +} + +# # ---- CHANGE PASSWORD ---- +# if ($action eq 'changepass') { +# my $new_pass = $q->param('new_password') || ''; +# my $confirm = $q->param('confirm_password') || ''; +# my $err = ''; + +# if ($new_pass eq '' || $confirm eq '') { +# $err = 'all fields required.'; +# } elsif (length($new_pass) < 4) { +# $err = 'password min 4 chars.'; +# } elsif ($new_pass ne $confirm) { +# $err = 'passwords do not match.'; +# } else { +# my $new_ph = md5_hex($new_pass); +# my $new_dir = "$STORAGE_ROOT/$c_uhash/$new_ph"; + +# if (-d $new_dir) { +# $err = 'new password cannot be the same as current.'; +# } elsif (rename($userdir, $new_dir)) { +# # Update session cookies with the new hash so the user stays logged in +# send_redirect($SCRIPT_NAME, make_login_cookies($c_uhash, $new_ph)); +# } else { +# $err = 'server error: could not update directory.'; +# } +# } +# # If there was an error, re-render the manager with a message +# $upload_msg = $err; +# $upload_cls = 'alert-err'; +# } + +# ---- DELETE ACCOUNT ---- +if ($action eq 'deleteaccount') { + my $confirm_pass = $q->param('confirm_password') || ''; + + if (md5_hex($confirm_pass) eq $c_phash) { + # 1. Define the user's root folder (the one containing the password hash folder) + my $user_root_dir = "$STORAGE_ROOT/$c_uhash"; + + # 2. Recursive delete helper (since rmdir only works on empty dirs) + my $delete_sub = sub { + my ($self, $path) = @_; + if (-d $path) { + opendir(my $dh, $path); + while (my $file = readdir($dh)) { + next if $file eq "." || $file eq ".."; + $self->($self, "$path/$file"); + } + closedir($dh); + rmdir($path); + } else { + unlink($path); + } + }; + + # 3. Execute deletion of the entire user hash directory + $delete_sub->($delete_sub, $user_root_dir); + + # 4. Clear cookies and send to login + my $c1 = CGI::Cookie->new(-name => 'uhash', -value => '', -expires => '-1d', -path => '/'); + my $c2 = CGI::Cookie->new(-name => 'phash', -value => '', -expires => '-1d', -path => '/'); + print $q->header(-status => '302 Found', -location => $SCRIPT_NAME, -cookie => [$c1, $c2]); + exit; + } else { + $upload_msg = 'incorrect password - account not deleted.'; + $upload_cls = 'alert-err'; + } +} + +# ---- DELETE ---- +if ($action eq 'delete') { + my $file = safe_name($q->param('file') || ''); + unlink "$userdir/$file" if -f "$userdir/$file"; + send_redirect($SCRIPT_NAME); +} + +# ---- UPLOAD ---- +if ($action eq 'upload') { + my $fh = $q->upload('file'); + my $name = $q->param('file') || ''; + if (!$fh) { + $upload_cls = 'alert-err'; + my $cerr = $q->cgi_error || ''; + if ($cerr =~ /too large/i || ($ENV{CONTENT_LENGTH}||0) > $MAX_UPLOAD) { + $upload_msg = 'file exceeds the 1 GB limit.'; + } else { + $upload_msg = 'no file selected.'; + } + } else { + my $safe = safe_name($name); + my $dest = "$userdir/$safe"; + my $wrote = 0; + if (!open my $out, '>', $dest) { + $upload_cls = 'alert-err'; + $upload_msg = 'server error: cannot write file.'; + } else { + binmode $out; + my $buf; + while (my $r = read $fh, $buf, 65536) { print $out $buf; $wrote += $r; + } + close $out; + $upload_msg = "uploaded: $safe (" . fmt_size($wrote) . ")"; + } + } +} + +# ---- FILE MANAGER ---- +send_page_header(); +print html_open("CloudVista"); +print "
\n"; + +print "
" + . "
CloudVista
" + . +"" + . "
\n"; +if ($upload_msg) { + print "
" . esc($upload_msg) . "
\n"; +} + +# upload form +print "
\n" + . "

Upload

\n" + . +"
\n" + . "\n" + . +"\n" + . "\n" + . +"\n" + . "
\n" + . "
\n"; + +# settings / change password & delete account +print "
\n" + . "
\n" + . "" + . "  [+] Account Settings" + . "\n" + + # Password Change Section + . "
\n" + . "

Change Password

\n" + . "
\n" + . "\n" + . "\n" + . "\n" + . "\n" + . "\n" + . "\n" + . "\n" + . "\n" + . "
\n" + . "
\n" + + # Delete Account Section + . "
\n" + . "

Danger Zone

\n" + . "

Deleting your account will permanently remove all your uploaded files.

\n" + . "
\n" + . "\n" + . "\n" + . "\n" + . "\n" + . "
\n" + . "
\n" + + . "
\n" + . "
\n"; +# # settings / change password form in a toggle drawer +# print "
\n" +# . "
\n" # The "Drawer" container +# . "" +# . "  [+] Account Settings / Change Password" +# . "\n" +# . "
\n" +# . "
\n" +# . "\n" + +# . "\n" +# . "\n" + +# . "\n" +# . "\n" + +# . "\n" +# . "\n" + +# . "\n" +# . "
\n" +# . "
\n" +# . "
\n" +# . "
\n"; + +# # settings form +# print "
\n" +# . "

Settings

\n" +# . "
\n" +# . "\n" +# . "\n" +# . "\n" +# . "\n" +# . "\n" +# . "\n" +# . "
\n" +# . "
\n"; + +# file list +my @files; +if (opendir my $dh, $userdir) { + while (my $f = readdir $dh) { + next if $f eq '.' +|| $f eq '..'; + next unless -f "$userdir/$f"; + my @st = stat "$userdir/$f"; + push @files, { name => $f, size => $st[7]||0, mtime => $st[9]||0 }; + } + closedir $dh; +} +@files = sort { $b->{mtime} <=> $a->{mtime} } @files; + +print "
\n

Files

\n"; +if (@files) { + print "\n" + . +"\n" + . "\n"; +for my $f (@files) { + my $enc = $q->escape($f->{name}); + my $dname = esc($f->{name}); + my $sz = fmt_size($f->{size}); + my $dt = strftime("%Y-%m-%d %H:%M", localtime($f->{mtime})); + print "" + . +"" + . +"" + . +"" + . +"\n"; + } + + print "\n
NameSizeModified
$dname$sz$dt" + . +"" + . +"↓ download" + . +" " + . +"✕ delete" + . "
\n"; + my $used = dir_size($userdir); + my $pct = $MAX_UPLOAD > 0 ? int($used * 100 / $MAX_UPLOAD) : 0; + $pct = 100 if $pct > 100; + my $cnt = scalar @files; + print "
\n" + . "
" . fmt_size($used) . +" used — $cnt file(s)
\n"; + +} else { + print "
no files yet — upload something above
\n"; +} + +print "
\n"; # files card +print "
\n"; # shell +print html_close(); +exit; +############################################################## +# LOGIN / REGISTER FORM (called after headers already sent) +############################################################## +sub print_login_form { + my ($msg, $tab) = @_; + $tab ||= 'login'; + + my $ltab = $tab eq 'login' ? 'tab active' : 'tab'; + my $rtab = $tab eq 'register' ? 'tab active' : 'tab'; + + print "
\n"; + print "
CloudVista
\n"; + if ($msg) { + print "
" . esc($msg) . "
\n"; + } + + print "
\n" + . +"
" + . +"login" + . +"register" + . "
\n"; + if ($tab eq 'login') { + print "
\n" + . +"\n" + . +"\n" + . +"\n" + . +"\n" + . +"\n" + . +"\n" + . "
\n"; + } else { + print "
\n" + . +"\n" + . +"\n" + . +"\n" + . +"\n" + . +"\n" + . +"\n" + . +"\n" + . +"\n" + . "
\n"; + } + + print "
\n
\n
\n
\n"; +}