storage = $storage; } public function setServer(Server $server) { $this->storage = $server->getStorage(); $this->server = $server; } public function getAuthToken() { // HTTP_AUTHORIZATION might be missing in some installs $header = apache_request_headers()['Authorization'] ?? ''; if ($header && 0 === stripos($header, 'Bearer ')) { return trim(substr($header, strlen('Bearer '))); } elseif (!empty($_GET['access_token'])) { return trim($_GET['access_token']); } else { throw new Exception('No access_token was provided', 401); } } public function route(?string $uri = null): bool { if (!method_exists($this->storage, 'getWopiURI')) { throw new \LogicException('Storage class does not implement getWopiURI method'); } if (null === $uri) { $uri = $_SERVER['REQUEST_URI'] ?? '/'; } $uri = trim($uri, '/'); if (0 !== strpos($uri, 'wopi/files/')) { return false; } $uri = substr($uri, strlen('wopi/files/')); $this->server->log('WOPI: => %s', $uri); try { $auth_token = $this->getAuthToken(); $method = $_SERVER['REQUEST_METHOD']; $id = rawurldecode(strtok($uri, '/')); $action = trim(strtok(false), '/'); $uri = $this->storage->getWopiURI($id, $auth_token); if (!$uri) { throw new Exception('Invalid file ID or invalid token', 404); } $this->server->log('WOPI: => Found doc_uri: %s', $uri); // GetFile if ($action == 'contents' && $method == 'GET') { $this->server->log('WOPI: => GetFile'); $this->server->http_get($uri); } // PutFile elseif ($action == 'contents' && $method == 'POST') { $this->server->log('WOPI: => PutFile'); $this->server->http_put($uri); } // CheckFileInfo elseif (!$action && $method == 'GET') { $this->server->log('WOPI: => CheckFileInfo'); $this->getInfo($uri); } else { throw new Exception('Invalid URI', 404); } $this->server->log('WOPI: <= 200'); http_response_code(200); // This is required for Collabora } catch (Exception $e) { $this->server->log('WOPI: => %d: %s', $e->getCode(), $e->getMessage()); http_response_code($e->getCode()); echo json_encode(['error' => $e->getMessage()]); } return true; } protected function getInfo(string $uri): bool { $props = $this->storage->properties($uri, [ 'DAV::getcontentlength', 'DAV::getlastmodified', 'DAV::getetag', self::PROP_READ_ONLY, self::PROP_USER_NAME, self::PROP_USER_ID, ], 0); $modified = !empty($props['DAV::getlastmodified']) ? $props['DAV::getlastmodified']->format(DATE_ISO8601) : null; $size = $props['DAV::getcontentlength'] ?? null; $data = [ 'BaseFileName' => basename($uri), 'UserFriendlyName' => $props[self::PROP_USER_NAME] ?? 'User', 'OwnerId' => 0, 'UserId' => $props[self::PROP_USER_ID] ?? 0, 'Size' => $size, 'Version' => $props['DAV::getetag'] ?? md5($uri . $size . $modified), ]; if ($modified) { $data['LastModifiedTime'] = $modified; } $data['ReadOnly'] = $props['self::PROP_READ_ONLY'] ?? false; $data['UserCanWrite'] = !$data['ReadOnly']; $data['UserCanRename'] = !$data['ReadOnly']; $data['DisableCopy'] = $data['ReadOnly']; $data['UserCanNotWriteRelative'] = true; // This requires you to implement file name UI $data['DisablePrint'] = true; // Does not work currently // see https://forum.collaboraonline.com/t/cross-origin-frame-issue-when-printing-in-chrome/1514 $json = json_encode($data, JSON_PRETTY_PRINT); $this->server->log('WOPI: => Info: %s', $json); http_response_code(200); header('Content-Type: application/json', true); echo $json; return true; } /** * Return list of available editors * @param string $url WOPI client discovery URL (eg. http://localhost:8080/hosting/discovery for OnlyOffice) * @return an array containing a list of extensions and (eventually) a list of mimetypes * that can be handled by the editor server: * ['extensions' => [ * 'odt' => ['edit' => 'http://...', 'embedview' => 'http://'], * 'ods' => ... * ], 'mimetypes' => [ * 'application/vnd.oasis.opendocument.presentation' => ['edit' => 'http://'...], * ]] */ static public function discover(string $url): array { if (function_exists('curl_init')) { $c = curl_init($url); curl_setopt($c, CURLOPT_RETURNTRANSFER, true); $r = curl_exec($c); $code = curl_getinfo($c, CURLINFO_HTTP_CODE); if ($code != 200) { throw new \RuntimeException(sprintf("Discovery URL returned an error: %d\n%s", $code, $r)); } curl_close($c); } else { $r = file_get_contents($url); $ok = false; foreach ($http_response_header as $h) { if (0 === strpos($h, 'HTTP/') && false !== strpos($h, '200')) { $ok = true; break; } } if (!$ok || empty($r)) { throw new \RuntimeException(sprintf("Discovery URL returned an error:\n%s", $r)); } } if (false !== strpos($r, 'xpath('/wopi-discovery/net-zone/app') as $app) { $name = (string) $app['name']; $mime = null; if (strpos($name, '/')) { $mime = $name; if (!isset($mimetypes[$mime])) { $mimetypes[$mime] = []; } } foreach ($app->children() as $child) { if ($child->getName() != 'action') { continue; } $ext = (string) $child['ext']; $action = (string) $child['name']; $url = (string) $child['urlsrc']; if ($mime) { $mimetypes[$mime][$action] = $url; } else { if (!isset($extensions[$ext])) { $extensions[$ext] = []; } $extensions[$ext][$action] = $url; } } } unset($xml, $app, $child); return compact('extensions', 'mimetypes'); } /** * Return list of available options for editor URL * This is called "Discovery query parameters" by OnlyOffice: * https://api.onlyoffice.com/editors/wopi/discovery#wopi-standart */ public function getEditorAvailableOptions(string $url): array { $query = parse_url($url, PHP_URL_QUERY); preg_match_all('/<(\w+)=(\w+)&>/i', $query, $match, PREG_SET_ORDER); $options = []; foreach ($match as $m) { $options[$m[1]] = $m[2]; } return $options; } /** * Set query parameters for editor URL */ public function setEditorOptions(string $url, array $options = []): string { $url = parse_url($url); // Remove available options from URL $url['query'] = preg_replace('/<(\w+)=(\w+)&>/i', '', $url['query'] ?? ''); // Set options parse_str($url['query'], $params); $params = array_merge($params, $options); $host = $url['host'] . (!empty($url['port']) ? ':' . $url['port'] : ''); $query = count($params) ? '?' . http_build_query($params) : ''; $url = sprintf('%s://%s%s%s', $url['scheme'], $host, $url['path'], $query); return $url; } public function getEditorHTML(string $editor_url, string $document_uri, string $title = 'Document') { // You need to extend this method by creating a token for the document_uri first! // Return the token with the document properties using ::PROP_TOKEN $props = $this->storage->properties($document_uri, [self::PROP_TOKEN, self::PROP_TOKEN_TTL, self::PROP_FILE_URL], 0); if (count($props) != 3) { throw new Exception('Missing properties for document', 500); } $src = $props[self::PROP_FILE_URL] ?? null; $token = $props[self::PROP_TOKEN] ?? null; // access_token_TTL: A 64-bit integer containing the number of milliseconds since January 1, 1970 UTC and representing the expiration date and time stamp of the access_token. $token_ttl = $props[self::PROP_TOKEN_TTL] ?? (time() + 10 * 3600) * 1000; // Append WOPI host URL $url = $this->setEditorOptions($editor_url, ['WOPISrc' => $src]); if (!$token) { throw new Exception('Access forbidden: no token was created', 403); } return << {$title}
EOF; } /** * Returns a base64 string safe for URLs * @param string $str * @return string */ static public function base64_encode_url_safe($str) { return rtrim(strtr(base64_encode($str), '+/', '-_'), '='); } /** * Decodes a URL safe base64 string * @param string $str * @return string */ static public function base64_decode_url_safe($str) { return base64_decode(str_pad(strtr($str, '-_', '+/'), strlen($str) % 4, '=', STR_PAD_RIGHT)); } }