. A second file is then created by appending * .info to the file name. This file contains the contents of all parameters passed on the URL GET/POST in key:value format * (embedded line breaks are replaced with spaces, and non-scalar values are skipped). Note that this is the exact file name * with .info appended, so joe.info becomes joe.info.info * * Three parameters have special meaning * file is the key looked for for the actual file contents (any key in $_FILES is accepted) * if filename is set, it is sanitized and used to store the file on disk. Otherwise, the name sent by the client is * sanitized and used, falling back to a random file name. * if upload_type is missing or not a known type, and a [type.unknown] section is configured, that section is used. * if upload_type is set, it is mapped to a subdirectory (see Configuration), and the type's write_info flag decides whether the * .info file is created at all. Otherwise, storage_path itself is used. * Sanitizing replaces everything except letters, digits, underscore, dot and hyphen with a hyphen, and removes * any path components and leading dots. See function sanitize_filename * * If a file of the same name (or a .info file of that name) already exists, a numeric suffix (.1, .2, ...) is added. * * Configuration: * Settings are read from the first file named upload_file.ini found in (1) the current working directory, * (2) DOCUMENT_ROOT/.., (3) DOCUMENT_ROOT/../private. If none is found, the defaults below are used. Standard INI format; * see upload_file.ini.sample for all settings. Keep the file out of the web root if at all possible (the supplied * .htaccess denies access to it if it is not). * * Example Usage: * http://url?upload_type=sysinfo&filename=joe.info&report_date=2016-04-01 13:11:04&client=My Client Name&hostname=server.example.com&serialnumber=dd-app-005&report=now is the time for all good men * Obviously, all values must be URL encoded * * The file /sysinfo/joe.info will be created with the file contents * The file /sysinfo/joe.info.info will be created with the following contents * upload_type:sysinfo * filename:joe.info * report_date:2016-04-01 13:11:04 * client:My Client Name * hostname:server.example.com * serialnumber:dd-app-005 * report:now is the time for all good men * * On success, the size in bytes of each saved file is printed, one per line. On failure, an HTTP 4xx/5xx status is sent * with a text message. * * NOTE: if the storage directory doesn't exist, it is created as the web server user, with permissions dir_mode (default 0775) * * To create the storage directory manually, it must be writable by the web server user, ie if storing in storing it * in ~/camp/sysinfo_reports, the following should be run as root * mkdir -p ~camp/sysinfo_reports/http * chown camp:www-data ~camp/sysinfo_reports/http * chmod 775 ~camp/sysinfo_reports/http */ define ( 'VERSION', '1.1.0' ); define ( 'BUILD_DATE', '2026-10-18' ); define ( 'CONFIG_NAME', 'upload_file.ini' ); // Defaults, overridden by the configuration file $defaultConfig = array( 'storage_path' => '/srv/camp/reports', 'dir_mode' => '0775', 'unknown_type' => 'sanitize', // sanitize = use the sanitized type as the subdirectory, reject = HTTP 400, root = store in storage_path 'write_info' => true, 'info_suffix' => '.info', 'max_file_size' => 0, // bytes, 0 = no limit beyond PHP's own upload_max_filesize 'types' => array() // upload_type => array( 'dir' => subdirectory, 'write_info' => bool (optional, overrides global) ) ); /* Fail with an HTTP status and a plain text message */ function fail ( $code, $message ) { http_response_code( $code ); header( 'Content-Type: text/plain' ); print $message . "\n"; exit; } /* Look for the configuration file in cwd, DOCUMENT_ROOT/.. and DOCUMENT_ROOT/../private */ function findConfig () { $dirs = array( getcwd() ); if ( ! empty( $_SERVER['DOCUMENT_ROOT'] ) ) { $dirs[] = $_SERVER['DOCUMENT_ROOT'] . '/..'; $dirs[] = $_SERVER['DOCUMENT_ROOT'] . '/../private'; } foreach ( $dirs as $dir ) { if ( $dir && is_readable( "$dir/" . CONFIG_NAME ) ) return "$dir/" . CONFIG_NAME; } return false; } function loadConfig ( $defaults ) { $file = findConfig(); if ( ! $file ) return $defaults; $ini = parse_ini_file( $file, true, INI_SCANNER_TYPED ); if ( $ini === false ) fail( 500, 'Unable to parse configuration' ); $config = $defaults; foreach ( $defaults as $key => $default ) { if ( $key != 'types' && isset( $ini[$key] ) && ! is_array( $ini[$key] ) ) $config[$key] = $ini[$key]; } // each known type is a section named [type.NAME] with keys dir and (optionally) write_info foreach ( $ini as $section => $values ) { if ( strncmp( $section, 'type.', 5 ) == 0 && is_array( $values ) ) $config['types'][substr( $section, 5 )] = $values; } $config['storage_path'] = rtrim( $config['storage_path'], '/' ); return $config; } /* Keep only letters, digits, underscore, dot and hyphen; no path components, no leading dots */ function sanitize_filename ( $filename, $replace_char = '-' ) { $filename = basename( str_replace( '\\', '/', (string) $filename ) ); $filename = preg_replace( '/[^a-zA-Z0-9_.-]+/', $replace_char, $filename ); $filename = preg_replace( '/' . preg_quote( $replace_char, '/' ) . '+/', $replace_char, $filename ); $filename = ltrim( $filename, '.' ); return substr( $filename, 0, 200 ); } /* Resolve upload_type to array( directory under storage_path, whether to write the .info file ) */ function targetDirectory ( $config, $type ) { $path = $config['storage_path']; $writeInfo = (bool) $config['write_info']; // a [type.unknown] section catches a missing upload_type and any type not listed if ( ( $type === '' || ! isset( $config['types'][$type] ) ) && isset( $config['types']['unknown'] ) ) $type = 'unknown'; if ( $type === '' ) return array( $path, $writeInfo ); if ( isset( $config['types'][$type] ) ) { if ( isset( $config['types'][$type]['write_info'] ) ) $writeInfo = (bool) $config['types'][$type]['write_info']; $sub = isset( $config['types'][$type]['dir'] ) ? (string) $config['types'][$type]['dir'] : $type; // configured subdirectories may be nested (a/b) but never absolute or contain .. $parts = array_filter( explode( '/', $sub ), 'strlen' ); foreach ( $parts as $part ) { if ( $part == '..' || $part == '.' ) fail( 500, 'Invalid directory configured for upload_type' ); } return array( $parts ? $path . '/' . implode( '/', $parts ) : $path, $writeInfo ); } switch ( $config['unknown_type'] ) { case 'reject': fail( 400, 'Unknown upload_type' ); case 'root': return array( $path, $writeInfo ); default: $sub = sanitize_filename( $type ); return array( $sub === '' ? $path : $path . '/' . $sub, $writeInfo ); } } /* Remove line breaks so a value cannot forge additional key:value lines */ function flatten ( $value ) { return str_replace( array( "\r\n", "\r", "\n" ), ' ', $value ); } function buildInfo ( $params ) { $lines = array(); foreach ( $params as $key => $value ) { if ( is_scalar( $value ) || $value === null ) $lines[] = flatten( $key ) . ':' . flatten( (string) $value ); } return implode( "\n", $lines ); } /* Returns the size of the saved file, or throws RuntimeException */ function saveFile ( $fileInfo, $targetDir, $filename, $info, $config, $writeInfo ) { if ( is_array( $fileInfo['error'] ) || is_array( $fileInfo['tmp_name'] ) ) throw new RuntimeException( 'Multiple files per field are not supported', 400 ); if ( $fileInfo['error'] ) throw new RuntimeException( 'Upload error code ' . $fileInfo['error'], 400 ); if ( ! is_uploaded_file( $fileInfo['tmp_name'] ) ) throw new RuntimeException( 'Not an uploaded file', 400 ); if ( $config['max_file_size'] && filesize( $fileInfo['tmp_name'] ) > $config['max_file_size'] ) throw new RuntimeException( 'File too large', 413 ); if ( $filename === '' ) $filename = sanitize_filename( isset( $fileInfo['name'] ) ? $fileInfo['name'] : '' ); if ( $filename === '' ) $filename = bin2hex( random_bytes( 8 ) ); if ( ! is_dir( $targetDir ) && ! mkdir( $targetDir, octdec( $config['dir_mode'] ), true ) && ! is_dir( $targetDir ) ) throw new RuntimeException( 'Unable to create storage directory', 500 ); // Atomically reserve a name where neither the data file nor its info file exists $suffix = $writeInfo ? $config['info_suffix'] : ''; $targetPath = $targetDir . '/' . $filename; $n = 0; while ( true ) { $candidate = $n ? "$targetPath.$n" : $targetPath; if ( ( ! $writeInfo || ! file_exists( $candidate . $suffix ) ) && ( $handle = @fopen( $candidate, 'x' ) ) ) { fclose( $handle ); $targetPath = $candidate; break; } if ( ++$n > 1000 ) throw new RuntimeException( 'Unable to find an unused file name', 500 ); } if ( ! move_uploaded_file( $fileInfo['tmp_name'], $targetPath ) ) { @unlink( $targetPath ); throw new RuntimeException( 'Could not save file', 500 ); } if ( $writeInfo && file_put_contents( $targetPath . $suffix, $info ) === false ) throw new RuntimeException( 'File saved but info file could not be written', 500 ); return filesize( $targetPath ); } $config = loadConfig( $defaultConfig ); $params = $_POST + $_GET; $type = isset( $params['upload_type'] ) && is_scalar( $params['upload_type'] ) ? (string) $params['upload_type'] : ''; $requested = isset( $params['filename'] ) && is_scalar( $params['filename'] ) ? sanitize_filename( $params['filename'] ) : ''; if ( ! $_FILES ) fail( 400, 'No file uploaded' ); list( $targetDir, $writeInfo ) = targetDirectory( $config, $type ); $info = $writeInfo ? buildInfo( $params ) : ''; header( 'Content-Type: text/plain' ); foreach ( $_FILES as $fileInfo ) { try { print saveFile( $fileInfo, $targetDir, $requested, $info, $config, $writeInfo ) . "\n"; } catch ( RuntimeException $e ) { fail( $e->getCode() ?: 500, $e->getMessage() ); } // a requested file name applies to one file only; further files get their own names $requested = ''; }