id = $id; $this->data = $data; } /** * Starts a new job. Fails if another job is running. * * @param string $type export|import. * @param array $args Initial state. * @return Shiftbox_Job|WP_Error */ public static function create($type, array $args) { if (! Shiftbox_Storage::ensure()) { return new WP_Error('shiftbox_storage', __('The backup folder (uploads/shiftbox-backups) is not writable. Ask your host to allow PHP to write to the uploads folder.', 'shiftbox')); } $active = self::active(); if ($active) { return new WP_Error('shiftbox_busy', __('Another backup or restore is running. Wait for it to finish or cancel it first.', 'shiftbox')); } $id = gmdate('YmdHis') . '-' . strtolower(wp_generate_password(8, false)); $dir = Shiftbox_Storage::jobs_dir() . '/' . $id; if (! wp_mkdir_p($dir)) { return new WP_Error('shiftbox_storage', __('Could not create a working folder inside uploads/shiftbox-backups.', 'shiftbox')); } $job = new self( $id, array_merge( array( 'type' => $type, 'step' => 'start', 'progress' => 0, 'message' => '', 'created' => time(), 'log' => array(), ), $args ) ); $job->save(); self::set_active($id, $type); return $job; } /** * Loads a job. * * @param string $id Id. * @return Shiftbox_Job|null */ public static function load($id) { if (! is_string($id) || ! preg_match('/^\d{14}-[a-z0-9]{8}$/', $id)) { return null; } $file = Shiftbox_Storage::jobs_dir() . '/' . $id . '/state.json'; if (! is_file($file)) { return null; } $data = wp_json_file_decode($file, array('associative' => true)); return is_array($data) ? new self($id, $data) : null; } /** * Working folder. * * @param string $file Optional file inside it. * @return string */ public function path($file = '') { $dir = Shiftbox_Storage::jobs_dir() . '/' . $this->id; return $file ? $dir . '/' . $file : $dir; } /** * Writes state atomically. * * @return void */ public function save() { $tmp = $this->path('state.json.tmp'); file_put_contents($tmp, wp_json_encode($this->data)); @rename($tmp, $this->path('state.json')); self::heartbeat($this->id); } /** * Adds a log line (kept short). * * @param string $line Message. * @return void */ public function log($line) { $this->data['log'][] = gmdate('H:i:s') . ' ' . $line; if (count($this->data['log']) > 200) { $this->data['log'] = array_slice($this->data['log'], -200); } } /** * Exclusive lock so two requests never work on the same job. * * @return bool */ public function lock() { $this->lock = @fopen($this->path('lock'), 'c'); if (! $this->lock) { return false; } if (! flock($this->lock, LOCK_EX | LOCK_NB)) { fclose($this->lock); $this->lock = null; return false; } return true; } /** * Releases the lock. * * @return void */ public function unlock() { if ($this->lock) { flock($this->lock, LOCK_UN); fclose($this->lock); $this->lock = null; } } /** * Deletes the job folder and clears the active marker. * * @return void */ public function destroy() { $this->unlock(); Shiftbox_Storage::rrmdir($this->path()); $active = self::active(); if ($active && $active['id'] === $this->id) { @unlink(self::active_file()); } } /** * Path of the active-job marker. * * @return string */ private static function active_file() { return Shiftbox_Storage::dir() . '/active.json'; } /** * Marks a job as active. * * @param string $id Id. * @param string $type Type. * @return void */ private static function set_active($id, $type) { self::write_active( array( 'id' => $id, 'type' => $type, 'beat' => time(), ) ); } /** * Refreshes the heartbeat. * * @param string $id Id. * @return void */ private static function heartbeat($id) { $active = self::active(true); if ($active && $active['id'] === $id) { $active['beat'] = time(); self::write_active($active); } } /** * Writes the active-job marker atomically, so a reader never sees a half-written file. * * @param array $data Marker data. * @return void */ private static function write_active(array $data) { $tmp = self::active_file() . '.tmp'; file_put_contents($tmp, wp_json_encode($data)); @rename($tmp, self::active_file()); } /** * The running job, if any. Jobs silent for 30 minutes count as abandoned. * * @param bool $include_stale Return even if stale. * @return array|null id, type, beat. */ public static function active($include_stale = false) { $file = self::active_file(); if (! is_file($file)) { return null; } $data = wp_json_file_decode($file, array('associative' => true)); if (! is_array($data) || empty($data['id'])) { return null; } if (! $include_stale && (time() - (int) $data['beat']) > 1800) { return null; } if (! is_dir(Shiftbox_Storage::jobs_dir() . '/' . $data['id'])) { @unlink($file); return null; } return $data; } /** * Removes job folders older than a day that are not active. * * @return void */ public static function cleanup_stale() { $active = self::active(true); foreach ((array) glob(Shiftbox_Storage::jobs_dir() . '/*', GLOB_ONLYDIR) as $dir) { $id = basename($dir); if ($active && $active['id'] === $id && (time() - (int) $active['beat']) < 1800) { continue; } if (time() - (int) @filemtime($dir . '/state.json') > DAY_IN_SECONDS) { Shiftbox_Storage::rrmdir($dir); } } foreach ((array) glob(Shiftbox_Storage::uploads_dir() . '/*') as $file) { if (is_file($file) && 'index.php' !== basename($file) && '.htaccess' !== basename($file) && 'web.config' !== basename($file) && time() - (int) @filemtime($file) > 2 * DAY_IN_SECONDS) { @unlink($file); } } } }