diff --git a/include/ws_functions/pwg.images.php b/include/ws_functions/pwg.images.php index 09f3bbf0d..ef7286b9f 100644 --- a/include/ws_functions/pwg.images.php +++ b/include/ws_functions/pwg.images.php @@ -1446,6 +1446,294 @@ SELECT } } +/** + * API method + * Adds a chunk of an image. Chunks don't have to be uploaded in the right sort order. When the last chunk is added, they get merged. + * @since 11 + * @param mixed[] $params + * @option string username + * @option string password + * @option chunk int number of the chunk + * @option string chunk_sum MD5 sum of the chunk + * @option chunks int total number of chunks for this image + * @option string original_sum MD5 sum of the final image + * @option int[] category + * @option string filename + * @option string name (optional) + * @option string author (optional) + * @option string comment (optional) + * @option string date_creation (optional) + * @option int level + * @option string tag_ids (optional) - "tag_id,tag_id" + * @option int image_id (optional) + */ +function ws_images_uploadAsync($params, &$service) +{ + global $conf, $user, $logger; + + // additional check for some parameters + if (!preg_match('/^[a-fA-F0-9]{32}$/', $params['original_sum'])) + { + return new PwgError(WS_ERR_INVALID_PARAM, 'Invalid original_sum'); + } + + if (!try_log_user($params['username'], $params['password'], false)) + { + return new PwgError(999, 'Invalid username/password'); + } + + // build $user + // include(PHPWG_ROOT_PATH.'include/user.inc.php'); + $user = build_user($user['id'], false); + + if (!is_admin()) + { + return new PwgError(401, 'Admin status is required.'); + } + + if ($params['image_id'] > 0) + { + $query=' +SELECT COUNT(*) + FROM '. IMAGES_TABLE .' + WHERE id = '. $params['image_id'] .' +;'; + list($count) = pwg_db_fetch_row(pwg_query($query)); + if ($count == 0) + { + return new PwgError(404, __FUNCTION__.' : image_id not found'); + } + } + + // handle upload error as in ws_images_addSimple + // if (isset($_FILES['image']['error']) && $_FILES['image']['error'] != 0) + + $output_filepath_prefix = $conf['upload_dir'].'/buffer/'.$params['original_sum'].'-u'.$user['id']; + $chunkfile_path_pattern = $output_filepath_prefix.'-%03uof%03u.chunk'; + + $chunkfile_path = sprintf($chunkfile_path_pattern, $params['chunk']+1, $params['chunks']); + + // create the upload directory tree if not exists + if (!mkgetdir(dirname($chunkfile_path), MKGETDIR_DEFAULT&~MKGETDIR_DIE_ON_ERROR)) + { + return new PwgError(500, 'error during buffer directory creation'); + } + secure_directory(dirname($chunkfile_path)); + + // move uploaded file + move_uploaded_file($_FILES['file']['tmp_name'], $chunkfile_path); + $logger->debug(__FUNCTION__.' uploaded '.$chunkfile_path); + + // MD5 checksum + $chunk_md5 = md5_file($chunkfile_path); + if ($chunk_md5 != $params['chunk_sum']) + { + unlink($chunkfile_path); + $logger->error(__FUNCTION__.' '.$chunkfile_path.' MD5 checksum mismatched'); + return new PwgError(500, "MD5 checksum chunk file mismatched"); + } + + // are all chunks uploaded? + $chunk_ids_uploaded = array(); + for ($i = 1; $i <= $params['chunks']; $i++) + { + $chunkfile = sprintf($chunkfile_path_pattern, $i, $params['chunks']); + if ( file_exists($chunkfile) && ($fp = fopen($chunkfile, "rb"))!==false ) + { + $chunk_ids_uploaded[] = $i; + fclose($fp); + } + } + + if ($params['chunks'] != count($chunk_ids_uploaded)) + { + // all chunks are not yet available + $logger->debug(__FUNCTION__.' all chunks are not uploaded yet, maybe on next chunk, exit for now'); + return array('message' => 'chunks uploaded = '.implode(',', $chunk_ids_uploaded)); + } + + // all chunks available + $logger->debug(__FUNCTION__.' '.$params['original_sum'].' '.$params['chunks'].' chunks available, try now to get lock for merging'); + $output_filepath = $output_filepath_prefix.'.merged'; + + // chunks already being merged? + if ( file_exists($output_filepath) && ($fp = fopen($output_filepath, "rb"))!==false ) + { + // merge file already exists + fclose($fp); + $logger->error(__FUNCTION__.' '.$output_filepath.' already exists, another merge is under process'); + return array('message' => 'chunks uploaded = '.implode(',', $chunk_ids_uploaded)); + } + + // create merged and open it for writing only + $fp = fopen($output_filepath, "wb"); + if ( !$fp ) + { + // unable to create file and open it for writing only + $logger->error(__FUNCTION__.' '.$chunkfile_path.' unable to create merge file'); + return new PwgError(500, 'error while creating merged '.$chunkfile_path); + } + + // acquire an exclusive lock and keep it until merge completes + // this postpones another uploadAsync task running in another thread + if (!flock($fp, LOCK_EX)) + { + // unable to obtain lock + fclose($fp); + $logger->error(__FUNCTION__.' '.$chunkfile_path.' unable to obtain lock'); + return new PwgError(500, 'error while locking merged '.$chunkfile_path); + } + + $logger->debug(__FUNCTION__.' lock obtained to merge chunks'); + + // loop over all chunks + foreach ($chunk_ids_uploaded as $chunk_id) + { + $chunkfile_path = sprintf($chunkfile_path_pattern, $chunk_id, $params['chunks']); + + // chunk deleted by preceding merge? + if (!file_exists($chunkfile_path)) + { + // cancel merge + $logger->error(__FUNCTION__.' '.$chunkfile_path.' already merged'); + flock($fp, LOCK_UN); + fclose($fp); + return array('message' => 'chunks uploaded = '.implode(',', $chunk_ids_uploaded)); + } + + if (!fwrite($fp, file_get_contents($chunkfile_path))) + { + // could not append chunk + $logger->error(__FUNCTION__.' error merging chunk '.$chunkfile_path); + flock($fp, LOCK_UN); + fclose($fp); + + // delete merge file without returning an error + @unlink($output_filepath); + return new PwgError(500, 'error while merging chunk '.$chunk_id); + } + + $logger->debug(__FUNCTION__.' original_sum='.$params['original_sum'].', chunk '.$chunk_id.'/'.$params['chunks'].' merged'); + + // delete chunk and clear cache + unlink($chunkfile_path); + } + + // flush output before releasing lock + fflush($fp); + flock($fp, LOCK_UN); + fclose($fp); + + $logger->debug(__FUNCTION__.' merged file '.$output_filepath.' saved'); + + // MD5 checksum + $merged_md5 = md5_file($output_filepath); + + if ($merged_md5 != $params['original_sum']) + { + unlink($output_filepath); + $logger->error(__FUNCTION__.' '.$output_filepath.' MD5 checksum mismatched!'); + return new PwgError(500, "MD5 checksum merged file mismatched"); + } + + $logger->debug(__FUNCTION__.' '.$output_filepath.' MD5 checksum OK'); + + include_once(PHPWG_ROOT_PATH.'admin/include/functions_upload.inc.php'); + + $image_id = add_uploaded_file( + $output_filepath, + $params['filename'], + $params['category'], + $params['level'], + $params['image_id'], + $params['original_sum'] + ); + + $logger->debug(__FUNCTION__.' image_id after add_uploaded_file = '.$image_id); + + // and now, let's create tag associations + if (isset($params['tag_ids']) and !empty($params['tag_ids'])) + { + set_tags( + explode(',', $params['tag_ids']), + $image_id + ); + } + + // time to set other infos + $info_columns = array( + 'name', + 'author', + 'comment', + 'date_creation', + ); + + $update = array(); + foreach ($info_columns as $key) + { + if (isset($params[$key])) + { + $update[$key] = $params[$key]; + } + } + + if (count(array_keys($update)) > 0) + { + single_update( + IMAGES_TABLE, + $update, + array('id' => $image_id) + ); + } + + // final step, reset user cache + invalidate_user_cache(); + + // trick to bypass get_sql_condition_FandF + if (!empty($params['level']) and $params['level'] > $user['level']) + { + // this will not persist + $user['level'] = $params['level']; + } + + // delete chunks older than a week + $now = time(); + foreach (glob($conf['upload_dir'].'/buffer/'."*.chunk") as $file) + { + if (is_file($file)) + { + if ($now - filemtime($file) >= 60 * 60 * 24 * 7) // 7 days + { + $logger->info(__FUNCTION__.' delete '.$file); + unlink($file); + } + else + { + $logger->debug(__FUNCTION__.' keep '.$file); + } + } + } + + // delete merged older than a week + foreach (glob($conf['upload_dir'].'/buffer/'."*.merged") as $file) + { + if (is_file($file)) + { + if ($now - filemtime($file) >= 60 * 60 * 24 * 7) // 7 days + { + $logger->info(__FUNCTION__.' delete '.$file); + unlink($file); + } + else + { + $logger->debug(__FUNCTION__.' keep '.$file); + } + } + } + + return $service->invoke('pwg.images.getInfo', array('image_id' => $image_id)); +} + /** * API method * Check if an image exists by it's name or md5 sum diff --git a/ws.php b/ws.php index 627e0cd98..bceda3a04 100644 --- a/ws.php +++ b/ws.php @@ -485,6 +485,36 @@ function ws_addDefaultMethods( $arr ) $ws_functions_root . 'pwg.images.php', array('admin_only'=>true, 'post_only'=>true) ); + + $service->addMethod( + 'pwg.images.uploadAsync', + 'ws_images_uploadAsync', + array( + 'username' => array(), + 'password' => array('default'=>null), + 'chunk' => array('type'=>WS_TYPE_INT|WS_TYPE_POSITIVE), + 'chunk_sum' => array(), + 'chunks' => array('type'=>WS_TYPE_INT|WS_TYPE_POSITIVE), + 'original_sum' => array(), + 'category' => array('default'=>null, 'flags'=>WS_PARAM_FORCE_ARRAY, 'type'=>WS_TYPE_ID), + 'filename' => array(), + 'name' => array('default'=>null), + 'author' => array('default'=>null), + 'comment' => array('default'=>null), + 'date_creation' => array('default'=>null), + 'level' => array('default'=>0, 'maxValue'=>max($conf['available_permission_levels']), 'type'=>WS_TYPE_INT|WS_TYPE_POSITIVE), + 'tag_ids' => array('default'=>null, 'info'=>'Comma separated ids'), + 'image_id' => array('default'=>null, 'type'=>WS_TYPE_ID), + ), + 'Upload photo by chunks in a random order. +
Use the $_FILES[file] field for uploading file. +
Start with chunk 0 (zero). +
Set the form encoding to "form-data". +
You can update an existing photo if you define an existing image_id. +
Requires admin credentials.', + $ws_functions_root . 'pwg.images.php', + array('post_only'=>true) + ); $service->addMethod( 'pwg.images.delete',