static function fix($task) { $start = microtime(true); $total = $task->get("total"); if (empty($total)) { // mptt: 2 operations for every item $total = 2 * db::build()->count_records("items"); // album audit (permissions and bogus album covers): 1 operation for every album $total += db::build()->where("type", "=", "album")->count_records("items"); // one operation for each missing slug, name and access cache foreach (array("find_dupe_slugs", "find_dupe_names", "find_missing_access_caches") as $func) { foreach (self::$func() as $row) { $total++; } } $task->set("total", $total); $task->set("state", $state = self::FIX_STATE_START_MPTT); $task->set("ptr", 1); $task->set("completed", 0); } $completed = $task->get("completed"); $state = $task->get("state"); if (!module::get_var("gallery", "maintenance_mode")) { module::set_var("gallery", "maintenance_mode", 1); } // This is a state machine that checks each item in the database. It verifies the following // attributes for an item. // 1. Left and right MPTT pointers are correct // 2. The .htaccess permission files for restricted items exist and are well formed. // 3. The relative_path_cache and relative_url_cache values are set to null. // 4. there are no album_cover_item_ids pointing to missing items // // We'll do a depth-first tree walk over our hierarchy using only the adjacency data because // we don't trust MPTT here (that might be what we're here to fix!). Avoid avoid using ORM // calls as much as possible since they're expensive. // // NOTE: the MPTT check will only traverse items that have valid parents. It's possible that // we have some tree corruption where there are items with parent ids to non-existent items. // We should probably do something about that. while ($state != self::FIX_STATE_DONE && microtime(true) - $start < 1.5) { switch ($state) { case self::FIX_STATE_START_MPTT: $task->set("ptr", $ptr = 1); $task->set("stack", item::root()->id . ":L"); $state = self::FIX_STATE_RUN_MPTT; break; case self::FIX_STATE_RUN_MPTT: $ptr = $task->get("ptr"); $stack = explode(" ", $task->get("stack")); list($id, $ptr_mode) = explode(":", array_pop($stack)); if ($ptr_mode == "L") { $stack[] = "{$id}:R"; db::build()->update("items")->set("left_ptr", $ptr++)->where("id", "=", $id)->execute(); foreach (db::build()->select(array("id"))->from("items")->where("parent_id", "=", $id)->order_by("left_ptr", "ASC")->execute() as $child) { array_push($stack, "{$child->id}:L"); } } else { if ($ptr_mode == "R") { db::build()->update("items")->set("right_ptr", $ptr++)->set("relative_path_cache", null)->set("relative_url_cache", null)->where("id", "=", $id)->execute(); } } $task->set("ptr", $ptr); $task->set("stack", implode(" ", $stack)); $completed++; if (empty($stack)) { $state = self::FIX_STATE_START_DUPE_SLUGS; } break; case self::FIX_STATE_START_DUPE_SLUGS: $stack = array(); foreach (self::find_dupe_slugs() as $row) { list($parent_id, $slug) = explode(":", $row->parent_slug, 2); $stack[] = join(":", array($parent_id, $slug)); } if ($stack) { $task->set("stack", implode(" ", $stack)); $state = self::FIX_STATE_RUN_DUPE_SLUGS; } else { $state = self::FIX_STATE_START_DUPE_NAMES; } break; case self::FIX_STATE_RUN_DUPE_SLUGS: $stack = explode(" ", $task->get("stack")); list($parent_id, $slug) = explode(":", array_pop($stack)); // We want to leave the first one alone and update all conflicts to be random values. $fixed = 0; $conflicts = ORM::factory("item")->where("parent_id", "=", $parent_id)->where("slug", "=", $slug)->find_all(1, 1); if ($conflicts->count() && ($conflict = $conflicts->current())) { $task->log("Fixing conflicting slug for item id {$conflict->id}"); db::build()->update("items")->set("slug", $slug . "-" . (string) rand(1000, 9999))->where("id", "=", $conflict->id)->execute(); // We fixed one conflict, but there might be more so put this parent back on the stack // and try again. We won't consider it completed when we don't fix a conflict. This // guarantees that we won't spend too long fixing one set of conflicts, and that we // won't stop before all are fixed. $stack[] = "{$parent_id}:{$slug}"; break; } $task->set("stack", implode(" ", $stack)); $completed++; if (empty($stack)) { $state = self::FIX_STATE_START_DUPE_NAMES; } break; case self::FIX_STATE_START_DUPE_NAMES: $stack = array(); foreach (self::find_dupe_names() as $row) { list($parent_id, $name) = explode(":", $row->parent_name, 2); $stack[] = join(":", array($parent_id, $name)); } if ($stack) { $task->set("stack", implode(" ", $stack)); $state = self::FIX_STATE_RUN_DUPE_NAMES; } else { $state = self::FIX_STATE_START_ALBUMS; } break; case self::FIX_STATE_RUN_DUPE_NAMES: $stack = explode(" ", $task->get("stack")); list($parent_id, $name) = explode(":", array_pop($stack)); $fixed = 0; // We want to leave the first one alone and update all conflicts to be random values. $conflicts = ORM::factory("item")->where("parent_id", "=", $parent_id)->where("name", "=", $name)->find_all(1, 1); if ($conflicts->count() && ($conflict = $conflicts->current())) { $task->log("Fixing conflicting name for item id {$conflict->id}"); db::build()->update("items")->set("name", $name . "-" . (string) rand(1000, 9999))->where("id", "=", $conflict->id)->execute(); // We fixed one conflict, but there might be more so put this parent back on the stack // and try again. We won't consider it completed when we don't fix a conflict. This // guarantees that we won't spend too long fixing one set of conflicts, and that we // won't stop before all are fixed. $stack[] = "{$parent_id}:{$name}"; break; } $task->set("stack", implode(" ", $stack)); $completed++; if (empty($stack)) { $state = self::FIX_STATE_START_ALBUMS; } break; case self::FIX_STATE_START_ALBUMS: $stack = array(); foreach (db::build()->select("id")->from("items")->where("type", "=", "album")->execute() as $row) { $stack[] = $row->id; } $task->set("stack", implode(" ", $stack)); $state = self::FIX_STATE_RUN_ALBUMS; break; case self::FIX_STATE_RUN_ALBUMS: $stack = explode(" ", $task->get("stack")); $id = array_pop($stack); $item = ORM::factory("item", $id); if ($item->album_cover_item_id) { $album_cover_item = ORM::factory("item", $item->album_cover_item_id); if (!$album_cover_item->loaded()) { $item->album_cover_item_id = null; $item->save(); } } $everybody = identity::everybody(); $view_col = "view_{$everybody->id}"; $view_full_col = "view_full_{$everybody->id}"; $intent = ORM::factory("access_intent")->where("item_id", "=", $id)->find(); if ($intent->{$view_col} === access::DENY) { access::update_htaccess_files($item, $everybody, "view", access::DENY); } if ($intent->{$view_full_col} === access::DENY) { access::update_htaccess_files($item, $everybody, "view_full", access::DENY); } $task->set("stack", implode(" ", $stack)); $completed++; if (empty($stack)) { $state = self::FIX_STATE_START_MISSING_ACCESS_CACHES; } break; case self::FIX_STATE_START_MISSING_ACCESS_CACHES: $stack = array(); foreach (self::find_missing_access_caches() as $row) { $stack[] = $row->id; } if ($stack) { $task->set("stack", implode(" ", $stack)); $state = self::FIX_STATE_RUN_MISSING_ACCESS_CACHES; } else { $state = self::FIX_STATE_DONE; } break; case self::FIX_STATE_RUN_MISSING_ACCESS_CACHES: $stack = explode(" ", $task->get("stack")); $id = array_pop($stack); $access_cache = ORM::factory("access_cache"); $access_cache->item_id = $id; $access_cache->save(); $task->set("stack", implode(" ", $stack)); $completed++; if (empty($stack)) { // The new cache rows are there, but they're incorrectly populated so we have to fix // them. If this turns out to be too slow, we'll have to refactor // access::recalculate_permissions to allow us to do it in slices. access::recalculate_album_permissions(item::root()); $state = self::FIX_STATE_DONE; } break; } } $task->set("state", $state); $task->set("completed", $completed); if ($state == self::FIX_STATE_DONE) { $task->done = true; $task->state = "success"; $task->percent_complete = 100; module::set_var("gallery", "maintenance_mode", 0); } else { $task->percent_complete = round(100 * $completed / $total); } $task->status = t2("One operation complete", "%count / %total operations complete", $completed, array("total" => $total)); }
/** * Internal method to set a permission * * @param Group_Model $group * @param string $perm_name * @param Item_Model $item * @param boolean $value */ private static function _set(Group_Definition $group, $perm_name, $album, $value) { if (!$group instanceof Group_Definition) { throw new Exception("@todo PERMISSIONS_ONLY_WORK_ON_GROUPS"); } if (!$album->loaded()) { throw new Exception("@todo INVALID_ALBUM {$album->id}"); } if (!$album->is_album()) { throw new Exception("@todo INVALID_ALBUM_TYPE not an album"); } $access = model_cache::get("access_intent", $album->id, "item_id"); $access->__set("{$perm_name}_{$group->id}", $value); $access->save(); if ($perm_name == "view") { self::_update_access_view_cache($group, $album); } else { self::_update_access_non_view_cache($group, $perm_name, $album); } access::update_htaccess_files($album, $group, $perm_name, $value); model_cache::clear(); }
static function fix($task) { $start = microtime(true); $total = $task->get("total"); if (empty($total)) { $item_count = db::build()->count_records("items"); $total = 0; // mptt: 2 operations for every item $total += 2 * $item_count; // album audit (permissions and bogus album covers): 1 operation for every album $total += db::build()->where("type", "=", "album")->count_records("items"); // one operation for each dupe slug, dupe name, dupe base name, and missing access cache foreach (array("find_dupe_slugs", "find_dupe_names", "find_dupe_base_names", "find_missing_access_caches") as $func) { foreach (self::$func() as $row) { $total++; } } // one operation to rebuild path and url caches; $total += 1 * $item_count; $task->set("total", $total); $task->set("state", $state = self::FIX_STATE_START_MPTT); $task->set("ptr", 1); $task->set("completed", 0); } $completed = $task->get("completed"); $state = $task->get("state"); if (!module::get_var("gallery", "maintenance_mode")) { module::set_var("gallery", "maintenance_mode", 1); } // This is a state machine that checks each item in the database. It verifies the following // attributes for an item. // 1. Left and right MPTT pointers are correct // 2. The .htaccess permission files for restricted items exist and are well formed. // 3. The relative_path_cache and relative_url_cache values are set to null. // 4. there are no album_cover_item_ids pointing to missing items // // We'll do a depth-first tree walk over our hierarchy using only the adjacency data because // we don't trust MPTT here (that might be what we're here to fix!). Avoid avoid using ORM // calls as much as possible since they're expensive. // // NOTE: the MPTT check will only traverse items that have valid parents. It's possible that // we have some tree corruption where there are items with parent ids to non-existent items. // We should probably do something about that. while ($state != self::FIX_STATE_DONE && microtime(true) - $start < 1.5) { switch ($state) { case self::FIX_STATE_START_MPTT: $task->set("ptr", $ptr = 1); $task->set("stack", item::root()->id . "L1"); $state = self::FIX_STATE_RUN_MPTT; break; case self::FIX_STATE_RUN_MPTT: $ptr = $task->get("ptr"); $stack = explode(" ", $task->get("stack")); preg_match("/([0-9]+)([A-Z])([0-9]+)/", array_pop($stack), $matches); // e.g. "12345L10" list(, $id, $ptr_mode, $level) = $matches; // Skip the 0th entry of matches. switch ($ptr_mode) { case "L": // Albums could be parent nodes. $stack[] = "{$id}R{$level}"; db::build()->update("items")->set("left_ptr", $ptr++)->where("id", "=", $id)->execute(); $level++; foreach (db::build()->select(array("id", "type"))->from("items")->where("parent_id", "=", $id)->order_by("left_ptr", "DESC")->execute() as $child) { $stack[] = $child->type == "album" ? "{$child->id}L{$level}" : "{$child->id}B{$level}"; } $completed++; break; case "B": // Non-albums must be leaf nodes. db::build()->update("items")->set("left_ptr", $ptr++)->set("right_ptr", $ptr++)->set("level", $level)->set("relative_path_cache", null)->set("relative_url_cache", null)->where("id", "=", $id)->execute(); $completed += 2; // we updated two pointers break; case "R": db::build()->update("items")->set("right_ptr", $ptr++)->set("level", $level)->set("relative_path_cache", null)->set("relative_url_cache", null)->where("id", "=", $id)->execute(); $completed++; } $task->set("ptr", $ptr); $task->set("stack", implode(" ", $stack)); if (empty($stack)) { $state = self::FIX_STATE_START_DUPE_SLUGS; } break; case self::FIX_STATE_START_DUPE_SLUGS: $stack = array(); foreach (self::find_dupe_slugs() as $row) { list($parent_id, $slug) = explode(":", $row->parent_slug, 2); $stack[] = join(":", array($parent_id, $slug)); } if ($stack) { $task->set("stack", implode(" ", $stack)); $state = self::FIX_STATE_RUN_DUPE_SLUGS; } else { $state = self::FIX_STATE_START_DUPE_NAMES; } break; case self::FIX_STATE_RUN_DUPE_SLUGS: $stack = explode(" ", $task->get("stack")); list($parent_id, $slug) = explode(":", array_pop($stack)); // We want to leave the first one alone and update all conflicts to be random values. $fixed = 0; $conflicts = ORM::factory("item")->where("parent_id", "=", $parent_id)->where("slug", "=", $slug)->find_all(1, 1); if ($conflicts->count() && ($conflict = $conflicts->current())) { $task->log("Fixing conflicting slug for item id {$conflict->id}"); db::build()->update("items")->set("slug", $slug . "-" . (string) rand(1000, 9999))->where("id", "=", $conflict->id)->execute(); // We fixed one conflict, but there might be more so put this parent back on the stack // and try again. We won't consider it completed when we don't fix a conflict. This // guarantees that we won't spend too long fixing one set of conflicts, and that we // won't stop before all are fixed. $stack[] = "{$parent_id}:{$slug}"; break; } $task->set("stack", implode(" ", $stack)); $completed++; if (empty($stack)) { $state = self::FIX_STATE_START_DUPE_NAMES; } break; case self::FIX_STATE_START_DUPE_NAMES: $stack = array(); foreach (self::find_dupe_names() as $row) { list($parent_id, $name) = explode(":", $row->parent_name, 2); $stack[] = join(":", array($parent_id, $name)); } if ($stack) { $task->set("stack", implode(" ", $stack)); $state = self::FIX_STATE_RUN_DUPE_NAMES; } else { $state = self::FIX_STATE_START_DUPE_BASE_NAMES; } break; case self::FIX_STATE_RUN_DUPE_NAMES: // NOTE: This does *not* attempt to fix the file system! $stack = explode(" ", $task->get("stack")); list($parent_id, $name) = explode(":", array_pop($stack)); $fixed = 0; // We want to leave the first one alone and update all conflicts to be random values. $conflicts = ORM::factory("item")->where("parent_id", "=", $parent_id)->where("name", "=", $name)->find_all(1, 1); if ($conflicts->count() && ($conflict = $conflicts->current())) { $task->log("Fixing conflicting name for item id {$conflict->id}"); if (!$conflict->is_album() && preg_match("/^(.*)(\\.[^\\.\\/]*?)\$/", $conflict->name, $matches)) { $item_base_name = $matches[1]; $item_extension = $matches[2]; // includes a leading dot } else { $item_base_name = $conflict->name; $item_extension = ""; } db::build()->update("items")->set("name", $item_base_name . "-" . (string) rand(1000, 9999) . $item_extension)->where("id", "=", $conflict->id)->execute(); // We fixed one conflict, but there might be more so put this parent back on the stack // and try again. We won't consider it completed when we don't fix a conflict. This // guarantees that we won't spend too long fixing one set of conflicts, and that we // won't stop before all are fixed. $stack[] = "{$parent_id}:{$name}"; break; } $task->set("stack", implode(" ", $stack)); $completed++; if (empty($stack)) { $state = self::FIX_STATE_START_DUPE_BASE_NAMES; } break; case self::FIX_STATE_START_DUPE_BASE_NAMES: $stack = array(); foreach (self::find_dupe_base_names() as $row) { list($parent_id, $base_name) = explode(":", $row->parent_base_name, 2); $stack[] = join(":", array($parent_id, $base_name)); } if ($stack) { $task->set("stack", implode(" ", $stack)); $state = self::FIX_STATE_RUN_DUPE_BASE_NAMES; } else { $state = self::FIX_STATE_START_ALBUMS; } break; case self::FIX_STATE_RUN_DUPE_BASE_NAMES: // NOTE: This *does* attempt to fix the file system! So, it must go *after* run_dupe_names. $stack = explode(" ", $task->get("stack")); list($parent_id, $base_name) = explode(":", array_pop($stack)); $base_name_escaped = Database::escape_for_like($base_name); $fixed = 0; // We want to leave the first one alone and update all conflicts to be random values. $conflicts = ORM::factory("item")->where("parent_id", "=", $parent_id)->where("name", "LIKE", "{$base_name_escaped}.%")->where("type", "<>", "album")->find_all(1, 1); if ($conflicts->count() && ($conflict = $conflicts->current())) { $task->log("Fixing conflicting name for item id {$conflict->id}"); if (preg_match("/^(.*)(\\.[^\\.\\/]*?)\$/", $conflict->name, $matches)) { $item_base_name = $matches[1]; // unlike $base_name, this always maintains capitalization $item_extension = $matches[2]; // includes a leading dot } else { $item_base_name = $conflict->name; $item_extension = ""; } // Unlike conflicts found in run_dupe_names, these items are likely to have an intact // file system. Let's use the item save logic to rebuild the paths and rename the files // if possible. try { $conflict->name = $item_base_name . "-" . (string) rand(1000, 9999) . $item_extension; $conflict->validate(); // If we get here, we're safe to proceed with save $conflict->save(); } catch (Exception $e) { // Didn't work. Edit database directly without fixing file system. db::build()->update("items")->set("name", $item_base_name . "-" . (string) rand(1000, 9999) . $item_extension)->where("id", "=", $conflict->id)->execute(); } // We fixed one conflict, but there might be more so put this parent back on the stack // and try again. We won't consider it completed when we don't fix a conflict. This // guarantees that we won't spend too long fixing one set of conflicts, and that we // won't stop before all are fixed. $stack[] = "{$parent_id}:{$base_name}"; break; } $task->set("stack", implode(" ", $stack)); $completed++; if (empty($stack)) { $state = self::FIX_STATE_START_ALBUMS; } break; case self::FIX_STATE_START_ALBUMS: $stack = array(); foreach (db::build()->select("id")->from("items")->where("type", "=", "album")->execute() as $row) { $stack[] = $row->id; } $task->set("stack", implode(" ", $stack)); $state = self::FIX_STATE_RUN_ALBUMS; break; case self::FIX_STATE_RUN_ALBUMS: $stack = explode(" ", $task->get("stack")); $id = array_pop($stack); $item = ORM::factory("item", $id); if ($item->album_cover_item_id) { $album_cover_item = ORM::factory("item", $item->album_cover_item_id); if (!$album_cover_item->loaded()) { $item->album_cover_item_id = null; $item->save(); } } $everybody = identity::everybody(); $view_col = "view_{$everybody->id}"; $view_full_col = "view_full_{$everybody->id}"; $intent = ORM::factory("access_intent")->where("item_id", "=", $id)->find(); if ($intent->{$view_col} === access::DENY) { access::update_htaccess_files($item, $everybody, "view", access::DENY); } if ($intent->{$view_full_col} === access::DENY) { access::update_htaccess_files($item, $everybody, "view_full", access::DENY); } $task->set("stack", implode(" ", $stack)); $completed++; if (empty($stack)) { $state = self::FIX_STATE_START_REBUILD_ITEM_CACHES; } break; case self::FIX_STATE_START_REBUILD_ITEM_CACHES: $stack = array(); foreach (self::find_empty_item_caches(500) as $row) { $stack[] = $row->id; } $task->set("stack", implode(" ", $stack)); $state = self::FIX_STATE_RUN_REBUILD_ITEM_CACHES; break; case self::FIX_STATE_RUN_REBUILD_ITEM_CACHES: $stack = explode(" ", $task->get("stack")); if (!empty($stack)) { $id = array_pop($stack); $item = ORM::factory("item", $id); $item->relative_path(); // this rebuilds the cache and saves the item as a side-effect $task->set("stack", implode(" ", $stack)); $completed++; } if (empty($stack)) { // Try refilling the stack foreach (self::find_empty_item_caches(500) as $row) { $stack[] = $row->id; } $task->set("stack", implode(" ", $stack)); if (empty($stack)) { $state = self::FIX_STATE_START_MISSING_ACCESS_CACHES; } } break; case self::FIX_STATE_START_MISSING_ACCESS_CACHES: $stack = array(); foreach (self::find_missing_access_caches_limited(500) as $row) { $stack[] = $row->id; } $task->set("stack", implode(" ", $stack)); $state = self::FIX_STATE_RUN_MISSING_ACCESS_CACHES; break; case self::FIX_STATE_RUN_MISSING_ACCESS_CACHES: $stack = array_filter(explode(" ", $task->get("stack"))); // filter removes empty/zero ids if (!empty($stack)) { $id = array_pop($stack); $access_cache = ORM::factory("access_cache"); $access_cache->item_id = $id; $access_cache->save(); $task->set("stack", implode(" ", $stack)); $completed++; } if (empty($stack)) { // Try refilling the stack foreach (self::find_missing_access_caches_limited(500) as $row) { $stack[] = $row->id; } $task->set("stack", implode(" ", $stack)); if (empty($stack)) { // The new cache rows are there, but they're incorrectly populated so we have to fix // them. If this turns out to be too slow, we'll have to refactor // access::recalculate_permissions to allow us to do it in slices. access::recalculate_album_permissions(item::root()); $state = self::FIX_STATE_DONE; } } break; } } $task->set("state", $state); $task->set("completed", $completed); if ($state == self::FIX_STATE_DONE) { $task->done = true; $task->state = "success"; $task->percent_complete = 100; module::set_var("gallery", "maintenance_mode", 0); } else { $task->percent_complete = round(100 * $completed / $total); } $task->status = t2("One operation complete", "%count / %total operations complete", $completed, array("total" => $total)); }