diff --git a/README.md b/README.md index e94b215..2d979ce 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ A wiki for phpBB with version history, side-by-side compare, and a moderation qu - Edits can wait for approval: moderators get a **Pending articles** list with Approve and Disapprove. Approving a version older than the live one is blocked, so a stale draft can't overwrite newer content. - Moderators can take an article offline or delete it, and articles can be made sticky or set to redirect. - A contributions page for every member, at `/wiki/contributions/`, lists every version they saved across all articles, newest first. Author names on articles and in the version history link to it. Versions waiting for approval, and those of switched-off articles, show only to moderators and to the author. +- Files can be uploaded to an article by members who hold the **Can upload files to articles** permission. They are checked against the board's allowed file types and size limit, stored outside public view under `store/phpbbmodders_wiki/`, and read only through the wiki, which checks that the reader may see the article. In the text, `[[File:Diagram.png]]` shows an uploaded image and `[[File:notes.zip|the notes]]` links to any other file. The member who uploaded a file, or a moderator who may delete versions, can remove it, and deleting an article deletes its files. - Notifications when an edit is waiting for approval. - The Wiki link in the board header shows the Wikipedia icon by default. Under **Extensions > Wiki > Settings** in the ACP the icon can be changed to any [Font Awesome](https://fontawesome.com/v4.7.0/icons/) icon name, or left blank for no icon. - A **Start discussion** button on each article creates its discussion topic in a forum chosen under **Extensions > Wiki > Settings** in the ACP, and links the topic to the article. It shows to members who hold the "Can change the discussion topic for an article" permission and may post in that forum, and stays hidden until a forum is chosen. diff --git a/config/services.yml b/config/services.yml index cddd649..1ede67a 100644 --- a/config/services.yml +++ b/config/services.yml @@ -1,5 +1,6 @@ parameters: tables.wiki.article: '%core.table_prefix%wiki_article' + tables.wiki.file: '%core.table_prefix%wiki_file' services: _defaults: @@ -36,6 +37,23 @@ services: - '%core.root_path%' - '%core.php_ext%' + phpbbmodders.wiki.files: + class: phpbbmodders\wiki\wiki\files + arguments: + - '@auth' + - '@cache' + - '@config' + - '@dbal.conn' + - '@files.factory' + - '@controller.helper' + - '@request' + - '@template' + - '@user' + - '%tables.wiki.file%' + - '%tables.wiki.article%' + - '%core.root_path%' + - '%core.php_ext%' + phpbbmodders.wiki.main: class: phpbbmodders\wiki\controller\main arguments: @@ -49,6 +67,7 @@ services: - '@phpbbmodders.wiki.compare' - '@phpbbmodders.wiki.view' - '@phpbbmodders.wiki.discussion' + - '@phpbbmodders.wiki.files' - '%core.root_path%' - '%core.php_ext%' phpbbmodders.wiki.listener: @@ -98,6 +117,7 @@ services: - '@controller.helper' - '@user' - '%tables.wiki.article%' + - '@phpbbmodders.wiki.files' phpbbmodders.wiki.view: class: phpbbmodders\wiki\wiki\view arguments: @@ -113,6 +133,7 @@ services: - '%core.php_ext%' - '@phpbbmodders.wiki.links' - '@phpbbmodders.wiki.discussion' + - '@phpbbmodders.wiki.files' phpbbmodders.wiki.discussion: class: phpbbmodders\wiki\wiki\discussion arguments: @@ -136,6 +157,7 @@ services: - '@controller.helper' - '@language' - '@phpbbmodders.wiki.links' + - '@phpbbmodders.wiki.files' tags: - { name: event.listener } diff --git a/controller/main.php b/controller/main.php index ead68c4..8a6c54f 100644 --- a/controller/main.php +++ b/controller/main.php @@ -42,6 +42,9 @@ class main /** @var \phpbbmodders\wiki\wiki\discussion */ protected $discussion; + /** @var \phpbbmodders\wiki\wiki\files */ + protected $files; + /** @var string phpbb_root_path */ protected $phpbb_root_path; @@ -61,10 +64,11 @@ class main * @param \phpbbmodders\wiki\wiki\compare $compare Diff Wiki object * @param \phpbbmodders\wiki\wiki\view $view View Wiki object * @param \phpbbmodders\wiki\wiki\discussion $discussion Start discussion object + * @param \phpbbmodders\wiki\wiki\files $files Uploaded files object * @param string $phpbb_root_path * @param string $php_ext */ - public function __construct(\phpbb\auth\auth $auth, \phpbb\controller\helper $helper, \phpbb\request\request $request, \phpbb\template\template $template, \phpbb\user $user, \phpbbmodders\wiki\wiki\delete $delete, \phpbbmodders\wiki\wiki\edit $edit, \phpbbmodders\wiki\wiki\compare $compare, \phpbbmodders\wiki\wiki\view $view, \phpbbmodders\wiki\wiki\discussion $discussion, $phpbb_root_path, $php_ext) + public function __construct(\phpbb\auth\auth $auth, \phpbb\controller\helper $helper, \phpbb\request\request $request, \phpbb\template\template $template, \phpbb\user $user, \phpbbmodders\wiki\wiki\delete $delete, \phpbbmodders\wiki\wiki\edit $edit, \phpbbmodders\wiki\wiki\compare $compare, \phpbbmodders\wiki\wiki\view $view, \phpbbmodders\wiki\wiki\discussion $discussion, \phpbbmodders\wiki\wiki\files $files, $phpbb_root_path, $php_ext) { $this->auth = $auth; $this->helper = $helper; @@ -76,6 +80,7 @@ public function __construct(\phpbb\auth\auth $auth, \phpbb\controller\helper $he $this->compare = $compare; $this->view = $view; $this->discussion = $discussion; + $this->files = $files; $this->phpbb_root_path = $phpbb_root_path; $this->php_ext = $php_ext; } @@ -149,6 +154,15 @@ public function article($article) case 'start_discussion': return $this->discussion->start($article); + case 'upload': + return $this->files->upload($article); + + case 'file': + return $this->files->serve($article, $id); + + case 'delete_file': + return $this->files->delete($article, $id); + default: return $this->view->view_article($article, $id); } diff --git a/docs/TODO.md b/docs/TODO.md index 3b31dc3..d30b072 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -164,19 +164,3 @@ BBCode comfortably. A one-click "Restore this version" in the version history. Today the only way is to approve the old version again. - -## File uploads - -Let editors upload images and files to an article, the way posts take -attachments. phpBB's attachment system is built around posts and private -messages, so the wiki either needs its own upload handling or a careful -reuse of phpBB's file upload classes with its own table. - -- Uploads belong to an article and survive across its versions; deleting - an article deletes them. -- Reuse the board's allowed file types, size limits and quota where - possible, with a separate permission to upload. -- Serve files through the extension's own controller, which checks the - reader's wiki permissions, never straight from a public folder. -- An uploaded image can be placed in the article text, for example with - `[[File:Diagram.png]]`. diff --git a/event/listener.php b/event/listener.php index 4266491..e21a60a 100644 --- a/event/listener.php +++ b/event/listener.php @@ -94,6 +94,10 @@ public function permissions($event) 'lang' => 'ACL_U_WIKI_EDIT_TOPIC', 'cat' => 'wiki' ), + 'u_wiki_upload' => array( + 'lang' => 'ACL_U_WIKI_UPLOAD', + 'cat' => 'wiki' + ), 'u_wiki_set_sticky' => array( 'lang' => 'ACL_U_WIKI_SET_STICKY', 'cat' => 'wiki' diff --git a/event/wikilink_listener.php b/event/wikilink_listener.php index bc1ffad..125b4d0 100644 --- a/event/wikilink_listener.php +++ b/event/wikilink_listener.php @@ -35,13 +35,17 @@ class wikilink_listener implements EventSubscriberInterface /** @var \phpbbmodders\wiki\wiki\links */ protected $links; + /** @var \phpbbmodders\wiki\wiki\files */ + protected $files; + /** * @param \phpbb\controller\helper $helper Controller helper object * @param \phpbb\language\language $language Language object * @param \phpbbmodders\wiki\wiki\links $links Wiki links service */ - public function __construct(\phpbb\controller\helper $helper, \phpbb\language\language $language, \phpbbmodders\wiki\wiki\links $links) + public function __construct(\phpbb\controller\helper $helper, \phpbb\language\language $language, \phpbbmodders\wiki\wiki\links $links, \phpbbmodders\wiki\wiki\files $files) { + $this->files = $files; $this->helper = $helper; $this->language = $language; $this->links = $links; @@ -72,6 +76,18 @@ public function configure_wikilink($event) return; } + // [[File:name]] and [[File:name|alt text]] embed one of the article's uploaded files. + // It is added first, and WIKILINK leaves "File:" names to it. + $file_tag = $configurator->tags->add('WIKIFILE'); + $file_tag->attributes->add('name'); + $file_tag->attributes->add('alt')->required = false; + $file_tag->template = ''; + + $configurator->Preg->match( + '/\[\[\s*File:\s*(?' . self::NAME_PATTERN . '?)\s*(?:\|\s*(?[^\[\]\n]{1,255}?)\s*)?\]\]/', + 'WIKIFILE' + ); + $tag = $configurator->tags->add('WIKILINK'); $tag->attributes->add('name'); $tag->attributes->add('text')->required = false; @@ -80,7 +96,7 @@ public function configure_wikilink($event) . ''; $configurator->Preg->match( - '/\[\[\s*(?' . self::NAME_PATTERN . '?)\s*(?:\|\s*(?[^\[\]\n]{1,255}?)\s*)?\]\]/', + '/\[\[\s*(?!File:)(?' . self::NAME_PATTERN . '?)\s*(?:\|\s*(?[^\[\]\n]{1,255}?)\s*)?\]\]/', 'WIKILINK' ); } @@ -95,6 +111,13 @@ public function configure_wikilink($event) public function link_wikilinks($event) { $html = $event['html']; + + if (strpos($html, 'class="wikifile"') !== false) + { + $html = $this->embed_files($html); + $event['html'] = $html; + } + if (strpos($html, 'class="wikilink"') === false) { return; @@ -123,4 +146,48 @@ public function link_wikilinks($event) . htmlspecialchars($this->language->lang('WIKI_LINK_NEW_ARTICLE'), ENT_COMPAT, 'UTF-8') . '">'; }, $html); } + + /** + * Replace each [[File:name]] placeholder with the file's image or link. + * + * Files are looked up among those of the article being rendered. Anywhere else + * (a forum post, a preview with no article) and for names that were never + * uploaded, the text stays as plain text. + * + * @param string $html + * @return string + */ + protected function embed_files($html) + { + $pattern = '/<\/span>/'; + if (!preg_match_all($pattern, $html, $matches)) + { + return $html; + } + + $article = $this->files->get_context(); + $names = array_map(function ($name) { + return htmlspecialchars_decode($name, ENT_QUOTES); + }, $matches[1]); + $found = ($article !== null) ? $this->files->get_files_by_name($article, $names) : array(); + + return preg_replace_callback($pattern, function ($match) use ($article, $found) { + $name = htmlspecialchars_decode($match[1], ENT_QUOTES); + $alt = ($match[2] !== '') ? $match[2] : $match[1]; + + if (!isset($found[$name])) + { + return '' . $alt . ''; + } + + $url = $this->files->get_url($article, $found[$name]['file_id']); + + if ($found[$name]['is_image']) + { + return '' . $alt . ''; + } + + return '' . $alt . ''; + }, $html); + } } diff --git a/language/de/common.php b/language/de/common.php index afae874..0da3efa 100644 --- a/language/de/common.php +++ b/language/de/common.php @@ -64,6 +64,16 @@ 'CONTRIBUTION_LIVE' => 'Aktive Version', 'CONTRIBUTION_REPLACED' => 'Durch eine neuere Version ersetzt', 'CONTRIBUTION_PENDING' => 'Wartet auf Freigabe oder inaktiv', + 'WIKI_FILES' => 'Dateien', + 'WIKI_NO_FILES' => 'Zu diesem Artikel wurden keine Dateien hochgeladen.', + 'WIKI_UPLOAD_FILE' => 'Datei hochladen', + 'WIKI_DELETE_FILE' => 'Datei löschen', + 'WIKI_FILE_UPLOADED' => 'Die Datei „%s“ wurde hochgeladen.', + 'WIKI_FILE_EXISTS' => 'Dieser Artikel hat bereits eine Datei namens „%s“. Lösche sie zuerst oder benenne die neue Datei um.', + 'WIKI_FILE_DELETED' => 'Die Datei „%s“ wurde gelöscht.', + 'WIKI_FILE_NOT_FOUND' => 'Die Datei existiert nicht.', + 'WIKI_CONFIRM_DELETE_FILE' => 'Bist du sicher, dass du die Datei „%s“ löschen möchtest?', + 'WIKI_FILE_MISSING' => 'Die Datei „%s“ wurde nicht zu diesem Artikel hochgeladen.', 'START_DISCUSSION' => 'Diskussion starten', 'DISCUSSION_READ_ARTICLE' => 'Artikel lesen', 'DISCUSSION_NO_FORUM' => 'Für das Wiki wurde kein Diskussionsforum eingerichtet.', diff --git a/language/de/permissions_wiki.php b/language/de/permissions_wiki.php index 7e5f083..3417144 100644 --- a/language/de/permissions_wiki.php +++ b/language/de/permissions_wiki.php @@ -42,6 +42,7 @@ 'ACL_M_WIKI_DELETE_ARTICLE' => 'Kann einen kompletten Artikel löschen', 'ACL_M_WIKI_VIEW_INACTIVE' => 'Kann inaktive Artikel anschauen', 'ACL_U_WIKI_EDIT_TOPIC' => 'Kann das Diskussionsthema zu einem Artikel ändern', + 'ACL_U_WIKI_UPLOAD' => 'Kann Dateien zu Artikeln hochladen', 'ACL_U_WIKI_SET_ACTIVE' => 'Kann die aktive Version eines Artikels ändern', 'ACL_U_WIKI_SET_STICKY' => 'Kann einen Artikel als wichtig markieren', 'ACL_U_WIKI_SET_REDIRECT' => 'Kann eine Weiterleitung in einem Artikel einrichten', diff --git a/language/en/common.php b/language/en/common.php index f3375f3..756b6f5 100644 --- a/language/en/common.php +++ b/language/en/common.php @@ -104,6 +104,16 @@ 'CONTRIBUTION_LIVE' => 'Live version', 'CONTRIBUTION_REPLACED' => 'Replaced by a newer version', 'CONTRIBUTION_PENDING' => 'Awaiting approval or inactive', + 'WIKI_FILES' => 'Files', + 'WIKI_NO_FILES' => 'No files have been uploaded to this article.', + 'WIKI_UPLOAD_FILE' => 'Upload file', + 'WIKI_DELETE_FILE' => 'Delete file', + 'WIKI_FILE_UPLOADED' => 'The file “%s” has been uploaded.', + 'WIKI_FILE_EXISTS' => 'This article already has a file called “%s”. Delete it first or rename the new one.', + 'WIKI_FILE_DELETED' => 'The file “%s” has been deleted.', + 'WIKI_FILE_NOT_FOUND' => 'The file does not exist.', + 'WIKI_CONFIRM_DELETE_FILE' => 'Are you sure you want to delete the file “%s”?', + 'WIKI_FILE_MISSING' => 'The file “%s” has not been uploaded to this article.', 'START_DISCUSSION' => 'Start discussion', 'DISCUSSION_READ_ARTICLE' => 'Read the article', 'DISCUSSION_NO_FORUM' => 'No discussion forum has been set up for the Wiki.', diff --git a/language/en/permissions_wiki.php b/language/en/permissions_wiki.php index 58ed91a..a6cccad 100644 --- a/language/en/permissions_wiki.php +++ b/language/en/permissions_wiki.php @@ -40,6 +40,7 @@ 'ACL_U_WIKI_VERSIONS' => 'Can view versions of an article', 'ACL_U_WIKI_DELETE' => 'Can delete versions of an article', 'ACL_U_WIKI_EDIT_TOPIC' => 'Can change the discussion topic for an article', + 'ACL_U_WIKI_UPLOAD' => 'Can upload files to articles', 'ACL_U_WIKI_SET_ACTIVE' => 'Can change the active version of an article', 'ACL_U_WIKI_SET_STICKY' => 'Can mark an article as important', 'ACL_U_WIKI_SET_REDIRECT' => 'Can set up a redirect on an article', diff --git a/migrations/add_file_uploads.php b/migrations/add_file_uploads.php new file mode 100644 index 0000000..38b2594 --- /dev/null +++ b/migrations/add_file_uploads.php @@ -0,0 +1,66 @@ + array( + $this->table_prefix . 'wiki_file' => array( + 'COLUMNS' => array( + 'file_id' => array('UINT', null, 'auto_increment'), + // The article's URL name: files belong to the article, not to one version of it + 'article_url' => array('VCHAR:255', ''), + // The name people see and write in [[File:name]] + 'file_name' => array('VCHAR:255', ''), + // The name on disk, which is random and has no extension + 'physical_name' => array('VCHAR:255', ''), + 'mimetype' => array('VCHAR:100', ''), + 'file_size' => array('UINT', 0), + 'is_image' => array('BOOL', 0), + 'user_id' => array('UINT', 0), + 'file_time' => array('TIMESTAMP', 0), + ), + 'PRIMARY_KEY' => 'file_id', + ), + ), + ); + } + + public function revert_schema() + { + return array( + 'drop_tables' => array( + $this->table_prefix . 'wiki_file', + ), + ); + } + + public function update_data() + { + return array( + // Nobody has it until an administrator gives it + array('permission.add', array('u_wiki_upload', true)), + ); + } +} diff --git a/styles/prosilver/template/article_body.html b/styles/prosilver/template/article_body.html index c4ad3d0..8cfcf1c 100644 --- a/styles/prosilver/template/article_body.html +++ b/styles/prosilver/template/article_body.html @@ -57,6 +57,35 @@

{L_SOURCES}

+ +

{L_WIKI_FILES}

+
+ + + +

{L_WIKI_NO_FILES}

+ + +
+
+ + + {S_FORM_TOKEN} +
+
+ +
+ +

{L_WIKI_WHAT_LINKS_HERE}

diff --git a/tests/unit/files_test.php b/tests/unit/files_test.php new file mode 100644 index 0000000..996817b --- /dev/null +++ b/tests/unit/files_test.php @@ -0,0 +1,98 @@ +assertSame($expected, files::clean_name($sent)); + } + + public static function names(): array + { + return array( + 'plain' => array('Diagram.png', 'Diagram.png'), + 'spaces kept' => array('my diagram.png', 'my diagram.png'), + 'path removed' => array('C:\\Users\\me\\Diagram.png', 'Diagram.png'), + 'unix path removed' => array('../../etc/passwd.png', 'passwd.png'), + 'link characters' => array('a[b]|c"e#f?g%h.png', 'a_b_c_d_e_f_g_h.png'), + 'control characters'=> array("a\x00b\nc.png", 'a_b_c.png'), + 'edge dots' => array(' ..hidden.png.. ', 'hidden.png'), + 'only junk' => array('...', ''), + 'long name' => array(str_repeat('a', 300) . '.png', str_repeat('a', 255)), + 'unicode kept' => array('Übersicht ✓.png', 'Übersicht ✓.png'), + ); + } + + public function test_only_real_web_images_are_shown_inside_the_browser() + { + foreach (array('image/png', 'image/jpeg', 'image/gif', 'image/webp') as $type) + { + $this->assertTrue(files::is_inline_image($type), $type); + } + + foreach (array('image/svg+xml', 'text/html', 'application/pdf', 'application/octet-stream', 'IMAGE/PNG', '') as $type) + { + $this->assertFalse(files::is_inline_image($type), $type); + } + } + + public function test_the_article_being_rendered_is_remembered_and_can_be_cleared() + { + files::set_context('Some_Article'); + $this->assertSame('Some_Article', files::get_context()); + + files::set_context(null); + $this->assertNull(files::get_context()); + } + + private function parse(string $text): string + { + $listener = new \phpbbmodders\wiki\event\wikilink_listener( + $this->createMock(\phpbb\controller\helper::class), + $this->createMock(\phpbb\language\language::class), + $this->createMock(\phpbbmodders\wiki\wiki\links::class), + $this->createMock(files::class) + ); + + $configurator = new \s9e\TextFormatter\Configurator; + $listener->configure_wikilink(array('configurator' => $configurator)); + extract($configurator->finalize()); + + return $parser->parse($text); + } + + public function test_a_file_embed_is_its_own_tag_and_not_a_link() + { + $xml = $this->parse('See [[File:Diagram.png]] and [[File:Diagram.png|the diagram]].'); + + $this->assertSame(2, substr_count($xml, 'assertStringContainsString('assertStringContainsString('alt="the diagram"', $xml); + $this->assertStringNotContainsString('parse('See [[Other article]] and [[Other article|this]], and [[Files overview]].'); + + $this->assertSame(3, substr_count($xml, 'assertStringNotContainsString('files = $files; $this->auth = $auth; $this->db = $db; $this->helper = $helper; @@ -110,6 +115,7 @@ public function article($article) $sql = 'DELETE FROM ' . $this->article_table . " WHERE article_url = '" . $this->db->sql_escape($article) . "'"; $this->db->sql_query($sql); + $this->files->delete_all($article); trigger_error($this->user->lang('DELETE_ARTICLE_SUCCESS') . '

' . $this->user->lang('BACK_TO_WIKI') . ''); } else diff --git a/wiki/edit.php b/wiki/edit.php index 49227e7..55efca8 100644 --- a/wiki/edit.php +++ b/wiki/edit.php @@ -243,7 +243,9 @@ public function edit_article($article) else if ($preview) // Display the preview { $this->message_parser->parse($this->option['bbcode'], $this->option['url'], $this->option['smilies'], $this->option['img'], $this->option['flash'], $this->option['quote']); + \phpbbmodders\wiki\wiki\files::set_context($article); $this->message_parser->format_display($this->option['bbcode'], $this->option['url'], $this->option['smilies']); + \phpbbmodders\wiki\wiki\files::set_context(null); foreach ($sources_array as $source) { diff --git a/wiki/files.php b/wiki/files.php new file mode 100644 index 0000000..64470a0 --- /dev/null +++ b/wiki/files.php @@ -0,0 +1,551 @@ +auth = $auth; + $this->cache = $cache; + $this->config = $config; + $this->db = $db; + $this->files_factory = $files_factory; + $this->helper = $helper; + $this->request = $request; + $this->template = $template; + $this->user = $user; + $this->file_table = $file_table; + $this->article_table = $article_table; + $this->phpbb_root_path = $phpbb_root_path; + $this->php_ext = $php_ext; + } + + /** + * Name the article whose text is about to be rendered; null when done. + * + * @param string|null $article_url + */ + public static function set_context($article_url) + { + self::$context = $article_url; + } + + /** + * @return string|null + */ + public static function get_context() + { + return self::$context; + } + + /** + * A display name that can be written inside [[File:...]]: no path, no + * characters the link syntax or a URL would trip over. + * + * @param string $name Name the browser sent + * @return string + */ + public static function clean_name($name) + { + $name = str_replace('\\', '/', (string) $name); + $name = basename($name); + $name = preg_replace('/[\[\]|<>"#?%\/\\\\\x00-\x1f]+/u', '_', $name); + $name = trim($name, " .\t"); + + return function_exists('mb_substr') ? mb_substr($name, 0, 255) : substr($name, 0, 255); + } + + /** + * Whether a stored type is shown inside the browser. + * + * @param string $mimetype + * @return bool + */ + public static function is_inline_image($mimetype) + { + return in_array($mimetype, self::INLINE_IMAGE_TYPES, true); + } + + /** + * Whether the viewer may upload. + * + * @return bool + */ + public function can_upload() + { + return (bool) $this->auth->acl_get('u_wiki_upload'); + } + + /** + * Whether the viewer may remove a file: the member who uploaded it, if they + * still may upload, or a moderator who may delete versions. + * + * @param array $file A wiki_file row + * @return bool + */ + public function can_delete(array $file) + { + return $this->auth->acl_get('m_wiki_delete') + || ($this->auth->acl_get('u_wiki_upload') && (int) $file['user_id'] === (int) $this->user->data['user_id'] && (int) $file['user_id'] > ANONYMOUS); + } + + /** + * The file types the board allows in every forum. + * + * @return string[] Lower-case extensions + */ + public function allowed_extensions() + { + $extensions = $this->cache->obtain_attach_extensions(0); + + return array_map('strval', array_keys($extensions['_allowed_'])); + } + + /** + * Full path of the folder, created (with files in it kept private) when it is missing. + * + * @return string With a trailing slash + */ + protected function directory() + { + $dir = $this->phpbb_root_path . self::DIRECTORY; + + if (!is_dir($dir)) + { + @mkdir($dir, 0755, true); + @file_put_contents($dir . '.htaccess', "# Files are only read through the wiki.\n\n\tRequire all denied\n\n\n\tOrder allow,deny\n\tDeny from all\n\n"); + @file_put_contents($dir . 'index.htm', ''); + } + + return $dir; + } + + /** + * An article's files, oldest first. + * + * @param string $article_url + * @return array wiki_file rows with the uploader's name + */ + public function get_files($article_url) + { + $sql = 'SELECT f.*, u.username, u.user_colour + FROM ' . $this->file_table . ' f + LEFT JOIN ' . USERS_TABLE . " u ON (u.user_id = f.user_id) + WHERE f.article_url = '" . $this->db->sql_escape($article_url) . "' + ORDER BY f.file_id ASC"; + $result = $this->db->sql_query($sql); + $rows = $this->db->sql_fetchrowset($result); + $this->db->sql_freeresult($result); + + return $rows; + } + + /** + * Files of an article by display name. + * + * @param string $article_url + * @param string[] $names + * @return array Name => wiki_file row + */ + public function get_files_by_name($article_url, array $names) + { + $names = array_values(array_unique(array_filter($names, 'strlen'))); + if (!$names) + { + return array(); + } + + $sql = 'SELECT file_id, file_name, is_image + FROM ' . $this->file_table . " + WHERE article_url = '" . $this->db->sql_escape($article_url) . "' + AND " . $this->db->sql_in_set('file_name', $names); + $result = $this->db->sql_query($sql); + $found = array(); + while ($row = $this->db->sql_fetchrow($result)) + { + $found[$row['file_name']] = $row; + } + $this->db->sql_freeresult($result); + + return $found; + } + + /** + * URL a file is read from. + * + * @param string $article_url + * @param int $file_id + * @return string + */ + public function get_url($article_url, $file_id) + { + return $this->helper->route('phpbbmodders_wiki_article', array('article' => $article_url, 'action' => 'file', 'id' => (int) $file_id)); + } + + /** + * Put the file list and the upload form on an article page. + * + * @param string $article_url + */ + public function assign_to_template($article_url) + { + foreach ($this->get_files($article_url) as $file) + { + $this->template->assign_block_vars('files', array( + 'NAME' => $file['file_name'], + 'SIZE' => get_formatted_filesize($file['file_size']), + 'UPLOADER' => get_username_string('no_profile', $file['user_id'], $file['username'], $file['user_colour']), + 'TIME' => $this->user->format_date($file['file_time']), + 'S_IMAGE' => (bool) $file['is_image'], + 'S_CAN_DELETE' => $this->can_delete($file), + 'U_FILE' => $this->get_url($article_url, $file['file_id']), + 'U_DELETE' => $this->helper->route('phpbbmodders_wiki_article', array('article' => $article_url, 'action' => 'delete_file', 'id' => (int) $file['file_id'])), + )); + } + + $can_upload = $this->can_upload(); + if ($can_upload) + { + add_form_key(self::FORM_KEY); + } + + $this->template->assign_vars(array( + 'S_WIKI_CAN_UPLOAD' => $can_upload, + 'U_WIKI_UPLOAD' => $this->helper->route('phpbbmodders_wiki_article', array('article' => $article_url, 'action' => 'upload')), + )); + } + + /** + * The live version of an article, or false when it has none. + * + * @param string $article_url + * @return array|false + */ + protected function get_live_article($article_url) + { + $sql = 'SELECT article_id, article_url + FROM ' . $this->article_table . " + WHERE article_url = '" . $this->db->sql_escape($article_url) . "' + AND article_approved = 1"; + $result = $this->db->sql_query_limit($sql, 1); + $row = $this->db->sql_fetchrow($result); + $this->db->sql_freeresult($result); + + return $row; + } + + /** + * Take the uploaded file, check it and store it. + * + * @param string $article_url + */ + public function upload($article_url) + { + if (!$this->can_upload()) + { + trigger_error('NOT_AUTHORISED'); + } + + if (!$this->get_live_article($article_url)) + { + trigger_error('NO_ARTICLE'); + } + + if (!check_form_key(self::FORM_KEY)) + { + trigger_error('FORM_INVALID'); + } + + $back = $this->helper->route('phpbbmodders_wiki_article', array('article' => $article_url)); + $back_link = '

' . $this->user->lang('BACK_TO_ARTICLE') . ''; + + // The upload class words its errors with keys from the posting language file + $this->user->add_lang('posting'); + + $upload = $this->files_factory->get('files.upload') + ->set_allowed_extensions($this->allowed_extensions()) + ->set_max_filesize((int) $this->config['max_filesize']); + + $file = $upload->handle_upload('files.types.form', 'wiki_file'); + + if (empty($file) || !$file->get('uploadname')) + { + trigger_error($this->user->lang('NO_UPLOAD_FORM_FOUND') . $back_link, E_USER_WARNING); + } + + if (count($file->error)) + { + $errors = $file->error; + $file->remove(); + trigger_error(implode('
', $errors) . $back_link, E_USER_WARNING); + } + + $name = self::clean_name($file->get('uploadname')); + if ($name === '') + { + $file->remove(); + trigger_error($this->user->lang('NO_UPLOAD_FORM_FOUND') . $back_link, E_USER_WARNING); + } + + if ($this->get_files_by_name($article_url, array($name))) + { + $file->remove(); + trigger_error($this->user->lang('WIKI_FILE_EXISTS', $name) . $back_link, E_USER_WARNING); + } + + $is_image = self::is_inline_image($file->get('mimetype')) && $file->is_image(); + + $file->clean_filename('unique', (int) $this->user->data['user_id'] . '_'); + $file->move_file($this->directory(), false, !$is_image); + + if (count($file->error)) + { + $errors = $file->error; + $file->remove(); + trigger_error(implode('
', $errors) . $back_link, E_USER_WARNING); + } + + // Store without the extension: nothing on disk can be run or sniffed by name + $stored = pathinfo($file->get('realname'), PATHINFO_FILENAME); + $dir = $this->directory(); + if ($stored !== $file->get('realname') && @rename($dir . $file->get('realname'), $dir . $stored)) + { + $physical = $stored; + } + else + { + $physical = $file->get('realname'); + } + + $sql_ary = array( + 'article_url' => $article_url, + 'file_name' => $name, + 'physical_name' => $physical, + 'mimetype' => $is_image ? $file->get('mimetype') : 'application/octet-stream', + 'file_size' => (int) $file->get('filesize'), + 'is_image' => $is_image ? 1 : 0, + 'user_id' => (int) $this->user->data['user_id'], + 'file_time' => time(), + ); + $this->db->sql_query('INSERT INTO ' . $this->file_table . ' ' . $this->db->sql_build_array('INSERT', $sql_ary)); + + meta_refresh(3, $back); + trigger_error($this->user->lang('WIKI_FILE_UPLOADED', $name) . $back_link); + } + + /** + * A file row that belongs to an article. + * + * @param string $article_url + * @param int $file_id + * @return array|false + */ + protected function get_file($article_url, $file_id) + { + $sql = 'SELECT * + FROM ' . $this->file_table . ' + WHERE file_id = ' . (int) $file_id . " + AND article_url = '" . $this->db->sql_escape($article_url) . "'"; + $result = $this->db->sql_query($sql); + $row = $this->db->sql_fetchrow($result); + $this->db->sql_freeresult($result); + + return $row; + } + + /** + * Send a file to the reader, if the reader may see the article. + * + * @param string $article_url + * @param int $file_id + * @return BinaryFileResponse + */ + public function serve($article_url, $file_id) + { + if (!$this->auth->acl_get('u_wiki_view')) + { + trigger_error('NOT_AUTHORISED'); + } + + // A switched-off article's files are as hidden as the article + if (!$this->get_live_article($article_url) && !$this->auth->acl_get('m_wiki_view_inactive')) + { + trigger_error('NO_ARTICLE'); + } + + $file = $this->get_file($article_url, $file_id); + $path = $file ? $this->directory() . basename($file['physical_name']) : ''; + + if (!$file || !is_file($path)) + { + trigger_error('WIKI_FILE_NOT_FOUND'); + } + + $inline = $file['is_image'] && self::is_inline_image($file['mimetype']); + + $response = new BinaryFileResponse($path); + $response->headers->set('Content-Type', $inline ? $file['mimetype'] : 'application/octet-stream'); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox"); + $response->headers->set('Cache-Control', 'private, max-age=3600'); + $response->setContentDisposition( + $inline ? ResponseHeaderBag::DISPOSITION_INLINE : ResponseHeaderBag::DISPOSITION_ATTACHMENT, + $file['file_name'], + preg_replace('/[^A-Za-z0-9._-]+/', '_', $file['file_name']) + ); + + return $response; + } + + /** + * Ask, then remove one file. + * + * @param string $article_url + * @param int $file_id + */ + public function delete($article_url, $file_id) + { + $file = $this->get_file($article_url, $file_id); + if (!$file) + { + trigger_error('WIKI_FILE_NOT_FOUND'); + } + + if (!$this->can_delete($file)) + { + trigger_error('NOT_AUTHORISED'); + } + + $back = $this->helper->route('phpbbmodders_wiki_article', array('article' => $article_url)); + + if (confirm_box(true)) + { + $this->remove_files(array($file)); + trigger_error($this->user->lang('WIKI_FILE_DELETED', $file['file_name']) . '

' . $this->user->lang('BACK_TO_ARTICLE') . ''); + } + + confirm_box(false, $this->user->lang('WIKI_CONFIRM_DELETE_FILE', $file['file_name']), build_hidden_fields(array('article' => $article_url, 'id' => (int) $file_id, 'action' => 'delete_file'))); + redirect($back); + } + + /** + * Remove every file of an article, as its deletion does. + * + * @param string $article_url + */ + public function delete_all($article_url) + { + $this->remove_files($this->get_files($article_url)); + } + + /** + * Delete files from disk and from the table. + * + * @param array $files wiki_file rows + */ + protected function remove_files(array $files) + { + if (!$files) + { + return; + } + + $dir = $this->directory(); + foreach ($files as $file) + { + $path = $dir . basename($file['physical_name']); + if (is_file($path)) + { + @unlink($path); + } + } + + $this->db->sql_query('DELETE FROM ' . $this->file_table . ' WHERE ' . $this->db->sql_in_set('file_id', array_map('intval', array_column($files, 'file_id')))); + } +} diff --git a/wiki/view.php b/wiki/view.php index 2114697..bdc8bce 100644 --- a/wiki/view.php +++ b/wiki/view.php @@ -42,6 +42,9 @@ class view extends \phpbbmodders\wiki\wiki\functions /** @var \phpbbmodders\wiki\wiki\discussion */ protected $discussion; + /** @var \phpbbmodders\wiki\wiki\files */ + protected $files; + /** @var string phpbb_root_path */ protected $phpbb_root_path; @@ -66,10 +69,12 @@ class view extends \phpbbmodders\wiki\wiki\functions * @param string $php_ext * @param \phpbbmodders\wiki\wiki\links $links Wiki links object * @param \phpbbmodders\wiki\wiki\discussion $discussion Start discussion object + * @param \phpbbmodders\wiki\wiki\files $files Uploaded files object */ - public function __construct(\phpbb\auth\auth $auth, \phpbb\db\driver\driver_interface $db, \phpbb\controller\helper $helper, \phpbb\template\template $template, \phpbb\user $user, \phpbbmodders\wiki\wiki\compare $compare, \phpbbmodders\wiki\wiki\edit $edit, $article_table, $phpbb_root_path, $php_ext, \phpbbmodders\wiki\wiki\links $links, \phpbbmodders\wiki\wiki\discussion $discussion) + public function __construct(\phpbb\auth\auth $auth, \phpbb\db\driver\driver_interface $db, \phpbb\controller\helper $helper, \phpbb\template\template $template, \phpbb\user $user, \phpbbmodders\wiki\wiki\compare $compare, \phpbbmodders\wiki\wiki\edit $edit, $article_table, $phpbb_root_path, $php_ext, \phpbbmodders\wiki\wiki\links $links, \phpbbmodders\wiki\wiki\discussion $discussion, \phpbbmodders\wiki\wiki\files $files) { $this->discussion = $discussion; + $this->files = $files; $this->links = $links; $this->auth = $auth; $this->db = $db; @@ -184,7 +189,10 @@ public function view_article($article, $id = 0) $this->message_parser->bbcode_bitfield = $this->data['bbcode_bitfield']; $this->message_parser->bbcode_uid = $this->data['bbcode_uid']; $allow_bbcode = $allow_magic_url = $allow_smilies = true; + // [[File:name]] in the text refers to this article's files + \phpbbmodders\wiki\wiki\files::set_context($this->data['article_url']); $this->message_parser->format_display($allow_bbcode, $allow_magic_url, $allow_smilies); + \phpbbmodders\wiki\wiki\files::set_context(null); if (!empty($this->data['article_redirect'])) { @@ -226,6 +234,8 @@ public function view_article($article, $id = 0) 'U_TOPIC' => ($this->data['article_topic_id'] <> 0) ? append_sid($this->phpbb_root_path . 'viewtopic.' . $this->php_ext, 't=' . $this->data['article_topic_id']) : '', )); + $this->files->assign_to_template($this->data['article_url']); + // Other articles whose live version links here with [[ ]]. foreach ($this->links->what_links_here($this->data['article_url']) as $linking) {