├── configuration.php ├── assets ├── index.html ├── css │ ├── index.html │ ├── select-search.min.css │ ├── select-search.css │ ├── style.min.css │ └── style.css └── js │ ├── index.html │ ├── variables.js │ ├── ip-address.min.js │ ├── currency-input.min.js │ ├── sign-in.min.js │ ├── ip-address.js │ ├── currency-input.js │ ├── sign-in.js │ ├── auto-complete.min.js │ ├── select-search.min.js │ ├── settings.min.js │ ├── search.min.js │ ├── auto-complete.js │ ├── provider.min.js │ ├── select-search.js │ ├── settings.js │ ├── provider.js │ └── search.js ├── includes ├── index.html └── functions.php ├── logs └── index.html ├── pages ├── index.html ├── json.user.php ├── json.currency.php ├── json.provider.php └── sign-in.php ├── storage └── index.html ├── libraries ├── index.html ├── autoload.php ├── Error │ └── Handler.php ├── Cookie │ └── Cookie.php ├── Session │ └── Session.php ├── Security │ └── Form.php ├── Http │ ├── Request.php │ └── Client.php └── SQLite │ └── Database.php ├── favicon.ico ├── api ├── .htaccess ├── config.php └── index.php ├── docker-compose.yml ├── LICENSE ├── index.php ├── 403.html ├── 50x.html ├── 404.html ├── install ├── tables.sql └── index.php └── README.md /configuration.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/index.html: -------------------------------------------------------------------------------- 1 | Access denied. -------------------------------------------------------------------------------- /includes/index.html: -------------------------------------------------------------------------------- 1 | Access denied. -------------------------------------------------------------------------------- /logs/index.html: -------------------------------------------------------------------------------- 1 | Access denied. -------------------------------------------------------------------------------- /pages/index.html: -------------------------------------------------------------------------------- 1 | Access denied. -------------------------------------------------------------------------------- /storage/index.html: -------------------------------------------------------------------------------- 1 | Access denied. -------------------------------------------------------------------------------- /assets/css/index.html: -------------------------------------------------------------------------------- 1 | Access denied. -------------------------------------------------------------------------------- /assets/js/index.html: -------------------------------------------------------------------------------- 1 | Access denied. -------------------------------------------------------------------------------- /libraries/index.html: -------------------------------------------------------------------------------- 1 | Access denied. -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seikan/carotu/HEAD/favicon.ico -------------------------------------------------------------------------------- /assets/js/variables.js: -------------------------------------------------------------------------------- 1 | const variables = { 2 | diskTypes: ['HDD', 'NVMe', 'SSD'], 3 | virtualizations: ['Dedicated', 'OpenStack', 'OpenVZ', 'HyperV', 'KVM', 'LXD', 'VMWare', 'XEN'], 4 | }; 5 | -------------------------------------------------------------------------------- /libraries/autoload.php: -------------------------------------------------------------------------------- 1 | 20 | Order allow,deny 21 | Deny from all 22 | 23 | -------------------------------------------------------------------------------- /assets/css/select-search.css: -------------------------------------------------------------------------------- 1 | .select-search { 2 | -webkit-user-select: none; 3 | -ms-user-select: none; 4 | user-select: none; 5 | } 6 | 7 | .select-search.disabled { 8 | background-color: var(--bs-secondary-bg); 9 | opacity: 1; 10 | } 11 | 12 | .select-search.focus { 13 | border-color: #86b7fe; 14 | outline: 0; 15 | box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); 16 | } 17 | 18 | .select-search-input:focus { 19 | border: var(--bs-border-width) solid var(--bs-border-color) !important; 20 | box-shadow: none !important; 21 | } 22 | 23 | .select-search-item a code { 24 | color: var(--bs-gray-700); 25 | background-color: var(--bs-warning-border-subtle); 26 | font-family: inherit; 27 | } 28 | -------------------------------------------------------------------------------- /assets/js/ip-address.min.js: -------------------------------------------------------------------------------- 1 | class IpAddress{constructor(){this.address4=/^(\d{1,3}\.){3}\d{1,3}$/,this.address6=/^([\da-f]{1,4}:){7}[\da-f]{1,4}$/i}isAddress4(s){return!!this.address4.test(s)&&s.split(".").every((s=>parseInt(s)<=255))}isAddress6(s){return!!this.address6.test(this.expandAddress6(s))&&s.split(":").every((s=>s.length<=4))}expandAddress6(s){if(null===s.match(/:/g))return s;if(7===s.match(/:/g).length)return s;var r=0,t=[],d=s.split("::");d.forEach((function(s,t){r+=s.split(":").length})),t.push(d[0]);for(var e=0;e<8-r;e++)t.push("0000");return t.push(d[1]),t.filter((s=>s)).forEach((function(s,r){t[r]=t[r].toString().padStart(4,"0")})),t.join(":")}isValid(s){return this.isAddress4(s)||this.isAddress6(s)}} -------------------------------------------------------------------------------- /assets/js/currency-input.min.js: -------------------------------------------------------------------------------- 1 | !function(i){i.fn.currencyInput=function(l){var e=i.extend({decimalDigit:2,decimalSymbol:"."},l);return i.each(this,(function(l,t){i(t).is("input")&&i(t).on("input",(function(){i(this).val(i(this).val().replace(new RegExp("[^0-9"+e.decimalSymbol+"]","g"),"")),i(this).val().indexOf(e.decimalSymbol)>-1&&i(this).val().match(new RegExp(e.decimalSymbol.replace(/[-[\]{}()*+?.,\\^$|#\s]/g,"\\$&"),"g")).length>1&&i(this).val(i(this).val().slice(0,-1)),i(this).val(i(this).val().replace(e.decimalSymbol,"")),i(this).val().length>e.decimalDigit?(i(this).val().length>e.decimalDigit+1&&i(this).val(i(this).val().replace(/^0/,"")),i(this).val(i(this).val().slice(0,-1*e.decimalDigit)+e.decimalSymbol+i(this).val().slice(-1*e.decimalDigit))):i(this).val("0"+e.decimalSymbol+"0".repeat(e.decimalDigit).slice(0,-1*i(this).val().length)+i(this).val())}))})),this}}(jQuery); -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | carotu: 3 | image: php:8.4-apache 4 | container_name: carotu 5 | restart: unless-stopped 6 | ports: 7 | - "80:80" 8 | volumes: 9 | - .:/var/www/html 10 | - ./storage:/var/www/html/storage 11 | environment: 12 | # Optional: For nginx-proxy setups, uncomment and configure: 13 | # - VIRTUAL_HOST=inventory.yourdomain.com 14 | # - LETSENCRYPT_HOST=inventory.yourdomain.com 15 | # - LETSENCRYPT_EMAIL=admin@yourdomain.com 16 | - VIRTUAL_PORT=80 17 | # For nginx-proxy networks, uncomment: 18 | # networks: 19 | # - webproxy 20 | 21 | # Enable Apache modules required for Carotu 22 | command: > 23 | bash -c " 24 | a2enmod rewrite headers && 25 | docker-php-ext-install pdo pdo_sqlite && 26 | apache2-foreground 27 | " 28 | 29 | # Uncomment if using nginx-proxy: 30 | # networks: 31 | # webproxy: 32 | # external: true 33 | # name: webproxy 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Sei Kan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /assets/js/sign-in.min.js: -------------------------------------------------------------------------------- 1 | $((function(){$("#username, #password").on("input",(function(){$('button[type="submit"]').toggleClass("disabled",!($("#username").val()&&$("#password").val()))})),$("#toggle-password").on("click",(function(){$("#toggle-password i").toggleClass("bi-eye bi-eye-slash"),$('input[name="password"]').attr("type",(function(i,a){return"password"==a?"text":"password"}))})),$('button[type="submit"]').on("click",(function(i){i.preventDefault();var a=$(this),t=$("form").serialize();a.data("html",a.html()).css({width:a.outerWidth(),height:a.outerHeight()}).prop("disabled",!0).html('
'),$("input").prop("disabled",!0),$(".is-invalid").removeClass("is-invalid"),$(".invalid-feedback").html(""),$.post("./user.json",t,(function(i){Object.keys(i.errors).length>0?$.each(i.errors,(function(i,a){$('[name="'+i+'"]').addClass("is-invalid"),$('[name="'+i+'"]').parent(".form-floating").addClass("is-invalid"),$("#"+i+"-feedback").html(a)})):window.location.href="./"}),"json").fail((function(){alert("Connection lost. Please try again.")})).always((function(i){a.prop("disabled",!1).html(a.data("html")),$("input").prop("disabled",!1),$('input[name="'+i.csrf.name+'"]').val(i.csrf.value)}))}))})); -------------------------------------------------------------------------------- /libraries/Error/Handler.php: -------------------------------------------------------------------------------- 1 | callback = $callback; 16 | 17 | // Register error handlers 18 | set_error_handler([$this, 'errorHandler']); 19 | set_exception_handler([$this, 'exceptionHandler']); 20 | register_shutdown_function([$this, 'shutdownHandler']); 21 | } 22 | 23 | public function errorHandler($code, $message, $file, $line) 24 | { 25 | if (error_reporting() == 0) { 26 | return; 27 | } 28 | 29 | // Route error to exception handler 30 | $this->exceptionHandler(new \ErrorException($message, $code, 0, $file, $line)); 31 | } 32 | 33 | public function exceptionHandler($exception) 34 | { 35 | \call_user_func($this->callback, $exception->getFile(), $exception->getLine(), $exception->getMessage()); 36 | } 37 | 38 | public function shutdownHandler() 39 | { 40 | if (($error = error_get_last()) === null) { 41 | return; 42 | } 43 | 44 | // Route error to exception handler 45 | $this->exceptionHandler(new \ErrorException($error['message'], $error['type'], 0, $error['file'], $error['line'])); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /assets/js/ip-address.js: -------------------------------------------------------------------------------- 1 | class IpAddress { 2 | constructor() { 3 | this.address4 = /^(\d{1,3}\.){3}\d{1,3}$/; 4 | this.address6 = /^([\da-f]{1,4}:){7}[\da-f]{1,4}$/i; 5 | } 6 | 7 | isAddress4(ip) { 8 | if (this.address4.test(ip)) { 9 | return ip.split('.').every((part) => parseInt(part) <= 255); 10 | } 11 | 12 | return false; 13 | } 14 | 15 | isAddress6(ip) { 16 | if (this.address6.test(this.expandAddress6(ip))) { 17 | return ip.split(':').every((part) => part.length <= 4); 18 | } 19 | 20 | return false; 21 | } 22 | 23 | expandAddress6(ip) { 24 | if (ip.match(/:/g) === null) { 25 | return ip; 26 | } 27 | 28 | // Check if groups of 8 29 | if (ip.match(/:/g).length === 7) { 30 | return ip; 31 | } 32 | 33 | var currentGroup = 0; 34 | var groups = []; 35 | 36 | // Split by empty groups 37 | var parts = ip.split('::'); 38 | 39 | parts.forEach(function (part, i) { 40 | currentGroup += part.split(':').length; 41 | }); 42 | 43 | groups.push(parts[0]); 44 | 45 | for (var i = 0; i < 8 - currentGroup; i++) { 46 | groups.push('0000'); 47 | } 48 | 49 | groups.push(parts[1]); 50 | 51 | groups 52 | .filter((ele) => ele) 53 | .forEach(function (group, i) { 54 | // Pad leading zeros 55 | groups[i] = groups[i].toString().padStart(4, '0'); 56 | }); 57 | 58 | return groups.join(':'); 59 | } 60 | 61 | isValid(ip) { 62 | return this.isAddress4(ip) || this.isAddress6(ip); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /api/config.php: -------------------------------------------------------------------------------- 1 | -1) { 27 | if ( 28 | $(this) 29 | .val() 30 | .match(new RegExp(settings.decimalSymbol.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'), 'g')).length > 1 31 | ) { 32 | $(this).val($(this).val().slice(0, -1)); 33 | } 34 | } 35 | 36 | // Remove decimal symbol for further processing 37 | $(this).val($(this).val().replace(settings.decimalSymbol, '')); 38 | 39 | if ($(this).val().length > settings.decimalDigit) { 40 | if ($(this).val().length > settings.decimalDigit + 1) { 41 | // Remove leading zero 42 | $(this).val($(this).val().replace(/^0/, '')); 43 | } 44 | 45 | $(this).val( 46 | $(this) 47 | .val() 48 | .slice(0, settings.decimalDigit * -1) + 49 | settings.decimalSymbol + 50 | $(this) 51 | .val() 52 | .slice(settings.decimalDigit * -1) 53 | ); 54 | } else { 55 | $(this).val('0' + settings.decimalSymbol + '0'.repeat(settings.decimalDigit).slice(0, $(this).val().length * -1) + $(this).val()); 56 | } 57 | }); 58 | }); 59 | 60 | return this; 61 | }; 62 | })(jQuery); 63 | -------------------------------------------------------------------------------- /assets/js/sign-in.js: -------------------------------------------------------------------------------- 1 | $(function () { 2 | $('#username, #password').on('input', function () { 3 | $('button[type="submit"]').toggleClass('disabled', !($('#username').val() && $('#password').val())); 4 | }); 5 | 6 | $('#toggle-password').on('click', function () { 7 | $('#toggle-password i').toggleClass('bi-eye bi-eye-slash'); 8 | 9 | $('input[name="password"]').attr('type', function (index, attr) { 10 | return attr == 'password' ? 'text' : 'password'; 11 | }); 12 | }); 13 | 14 | $('button[type="submit"]').on('click', function (e) { 15 | e.preventDefault(); 16 | 17 | var $btn = $(this); 18 | var values = $('form').serialize(); 19 | 20 | $btn.data('html', $btn.html()) 21 | .css({ 22 | width: $btn.outerWidth(), 23 | height: $btn.outerHeight(), 24 | }) 25 | .prop('disabled', true) 26 | .html('
'); 27 | 28 | $('input') 29 | .prop('disabled', true); 30 | 31 | $('.is-invalid').removeClass('is-invalid'); 32 | 33 | $('.invalid-feedback').html(''); 34 | 35 | $.post('./user.json', values, function(response) { 36 | if (Object.keys(response.errors).length > 0) { 37 | $.each(response.errors, function (key, value) { 38 | $('[name="' + key + '"]') 39 | .addClass('is-invalid'); 40 | 41 | $('[name="' + key + '"]').parent('.form-floating') 42 | .addClass('is-invalid'); 43 | 44 | $('#' + key + '-feedback') 45 | .html(value); 46 | }); 47 | 48 | return; 49 | } 50 | 51 | window.location.href = './'; 52 | }, 'json') 53 | .fail(function() { 54 | alert('Connection lost. Please try again.'); 55 | }) 56 | .always(function(response) { 57 | $btn.prop('disabled', false).html($btn.data('html')); 58 | 59 | $('input') 60 | .prop('disabled', false); 61 | 62 | $('input[name="' + response.csrf.name + '"]').val(response.csrf.value); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /pages/json.user.php: -------------------------------------------------------------------------------- 1 | [], 15 | 'csrf' => [ 16 | 'name' => $form->getInputName(), 17 | 'value' => $form->getInputValue(), 18 | ], 19 | ]; 20 | 21 | $username = $request->post('username'); 22 | $password = $request->post('password'); 23 | $remember = $request->post('remember'); 24 | 25 | // CSRF validation 26 | $csrf = $form->validate(); 27 | 28 | // Get the latest CSRF token 29 | $results['csrf'] = [ 30 | 'name' => $form->getInputName(), 31 | 'value' => $form->getInputValue(), 32 | ]; 33 | 34 | // CSRF token is invalid 35 | if (!$csrf) { 36 | $results['errors']['password'] = 'Security validation failed.'; 37 | exit(json_encode($results)); 38 | } 39 | 40 | // Username is not in a proper format 41 | if (!preg_match('/^[a-z0-9]{6,20}$/', $username)) { 42 | $results['errors']['username'] = 'Invalid username.'; 43 | } 44 | 45 | // Password is not in a proper format 46 | if (mb_strlen($password) < 8 || !preg_match('@[A-Z]@', $password) || !preg_match('@[a-z]@', $password) || !preg_match('@[0-9]@', $password) || !preg_match('@[^\w]@', $password)) { 47 | $results['errors']['password'] = 'Invalid password.'; 48 | } 49 | 50 | // Return errors 51 | if (!empty($results['errors'])) { 52 | exit(json_encode($results)); 53 | } 54 | 55 | // Match the username and password 56 | if ($username == $config['username'] && $password == $config['password']) { 57 | $session->set('user', 'yes'); 58 | 59 | // Store authentication details in cookie to avoid sign in again 60 | if ($remember) { 61 | $cookie->set('auth', $username . ';' . hash('sha256', $config['privateKey'] . $password . $config['privateKey'])); 62 | } 63 | 64 | $results['url'] = './'; 65 | 66 | exit(json_encode($results)); 67 | } 68 | 69 | $results['errors']['password'] = 'Invalid username or password.'; 70 | 71 | echo json_encode($results); 72 | -------------------------------------------------------------------------------- /assets/js/auto-complete.min.js: -------------------------------------------------------------------------------- 1 | !function(e){e.fn.autoComplete=function(t){var o=[],n="object"==typeof t?t:{},i=e.extend({source:[],noResults:"No result found"},n),a=function(t){if(e(t).is("input")&&Array.isArray(i.source)){o.push(t);var n=e('