Add early implementation of wiki pages

This commit is contained in:
Celestora 2021-12-13 13:17:13 +02:00
parent bb055f90aa
commit d1e55fd53d
17 changed files with 599 additions and 37 deletions

View file

@ -2,8 +2,8 @@
namespace openvk\Web\Models\Entities;
use openvk\Web\Util\DateTime;
use openvk\Web\Models\RowModel;
use openvk\Web\Models\Entities\{User, Manager};
use openvk\Web\Models\Repositories\{Users, Clubs, Albums, Managers};
use openvk\Web\Models\Entities\{User, Manager, WikiPage};
use openvk\Web\Models\Repositories\{Users, Clubs, Albums, Managers, WikiPages};
use Nette\Database\Table\{ActiveRow, GroupedSelection};
use Chandler\Database\DatabaseConnection as DB;
use Chandler\Security\User as ChandlerUser;
@ -328,6 +328,31 @@ class Club extends RowModel
])->delete();
}
function containsWiki(): bool
{
return (bool) $this->getRecord()->pages;
}
function getWikiHomePage(): ?WikiPage
{
return (new WikiPages)->getByOwnerAndVID($this->getId() * -1, 1);
}
function setWikiEnabled(bool $enable = true): void
{
if($enable) {
if(is_null((new WikiPages)->getByOwnerAndVID($this->getId() * -1, 1))) {
$page = new WikiPage;
$page->setOwner($this->getId() * -1);
$page->setTitle("Fresh News");
$page->setSource("");
$page->save();
}
}
$this->stateChanges("pages", (int) $enable);
}
function canBeModifiedBy(User $user): bool
{
$id = $user->getId();

View file

@ -0,0 +1,6 @@
<?php declare(strict_types=1);
namespace openvk\Web\Models\Entities;
interface ILinkable {
public function getOVKLink(): string;
}

View file

@ -0,0 +1,3 @@
<?php declare(strict_types=1);
class_alias('openvk\Web\Models\Entities\WikiPage\WikiPage', 'openvk\Web\Models\Entities\WikiPage');

View file

@ -0,0 +1,213 @@
<?php declare(strict_types=1);
namespace openvk\Web\Models\Entities\WikiPage;
use openvk\Web\Models\Repositories\WikiPages;
use Netcarver\Textile;
use HTMLPurifier_Config;
use HTMLPurifier;
class Parser
{
private $depth;
private $page;
private $repo;
private $vars;
private $ctx;
private $entityNames = [
"id" => [0, "User", "get", "getAvatarURL"],
"photo" => [1, "Photo", "getByOwnerAndVID", "getURL"],
"video" => [1, "Video", "getByOwnerAndVID", "getThumbnailURL"],
"note" => [1, "Note", "getNoteById", NULL],
"club" => [0, "Group", "get", "getAvatarURL"],
"wall" => [1, "Post", "getPostById", NULL],
];
const REFERENCE_SINGULAR = 0;
const REFERENCE_DUAL = 1;
function __construct(WikiPage $page, WikiPages $repo, int &$counter, array $vars = [], array $ctx = []) {
$this->depth = $counter;
$this->page = $page;
$this->repo = $repo;
$this->vars = $vars;
$this->ctx = $ctx;
}
private function getPurifier(): HTMLPurifier
{
$config = HTMLPurifier_Config::createDefault();
$config->set("Attr.AllowedClasses", ["unbordered", "inline", "nonexistent"]);
$config->set("Attr.DefaultInvalidImageAlt", "Unknown image");
$config->set("AutoFormat.AutoParagraph", true);
$config->set("AutoFormat.Linkify", true);
$config->set("URI.Base", "//$_SERVER[SERVER_NAME]/");
$config->set("URI.Munge", "/away.php?xinf=%n.%m:%r&css=%p&to=%s");
$config->set("URI.MakeAbsolute", true);
$config->set("HTML.Doctype", "XHTML 1.1");
$config->set("HTML.TidyLevel", "heavy");
$config->set("HTML.AllowedElements", [
"div",
"h3",
"h4",
"h5",
"h6",
"p",
"i",
"b",
"a",
"del",
"ins",
"sup",
"sub",
"table",
"thead",
"tbody",
"tr",
"td",
"th",
"img",
"ul",
"ol",
"li",
"hr",
"br",
"acronym",
"blockquote",
"cite",
]);
$config->set("HTML.AllowedAttributes", [
"table.summary",
"td.abbr",
"th.abbr",
"a.href",
"img.src",
"img.alt",
"img.style",
"div.style",
"div.title",
]);
$config->set("CSS.AllowedProperties", [
"float",
"height",
"width",
"max-height",
"max-width",
"font-weight",
]);
return new HTMLPurifier($config);;
}
private function resolveEntityURL(array $matches, bool $dual = false, bool $needImage = false): ?string
{
$descriptor = $this->entityNames[$matches[1]] ?? NULL;
if($descriptor && $descriptor[0] === ((int) $dual)) {
$repoClass = 'openvk\Web\Models\Repositories\\' . $descriptor[1] . 's';
$repoInst = new $repoClass;
$entity = $repoInst->{$descriptor[2]}(...array_map(function($x): int {
return (int) $x;
}, array_slice($matches, 2)));
if($entity) {
if($needImage) {
$serverUrl = ovk_scheme(true) . $_SERVER["SERVER_NAME"];
$thumbnailMethod = $descriptor[3];
if(!$thumbnailMethod)
return "$serverUrl/assets/packages/static/openvk/img/entity_nopic.png";
else
return $entity->{$thumbnailMethod}();
} else {
if(in_array('openvk\Web\Models\Entities\ILinkable', class_implements($entity)))
return $entity->getOVKLink();
}
}
}
return NULL;
}
private function resolvePageLinks(): string
{
return preg_replace_callback('%\[\[((?:\p{L}\p{M}?|[ 0-9\-\'_\/#])+)(\|(\p{L}\p{M}?|[ 0-9\-\'_\/]))?\]\]%u', function(array $matches): string {
$gid = $this->page->getOwner()->getId() * -1;
$page = $this->repo->getByOwnerAndTitle($gid, $matches[1]);
$title = $matches[3] ?? $matches[1];
$nTitle = htmlentities($title);
if(!$page)
return "\"(nonexistent)$nTitle\":/pages?elid=0&gid=" . ($gid * -1) . "&title=" . rawurlencode($title);
else
return "\"$nTitle\":/page" . $page->getPrettyId();
}, $this->page->getSource());
}
private function parseVariables(): string
{
$html = $this->resolvePageLinks();
return preg_replace_callback('%(?<!\\\\)\?(\$|#)([A-z_]|[A-z_][A-z_0-9]|(?:[A-z_][A-z_\-\\\'0-9]+[A-z_0-9]))\?%', function(array $matches): string {
return (string) (($matches[1] === "$" ? $this->ctx : $this->vars)[$matches[2]] ?? "<b>Notice: Unknown variable $matches[2]</b><br/>");
}, $html);
}
private function parseOvkTemplates(): string
{
$html = $this->parseVariables();
if($this->counter < 5) {
return preg_replace_callback('%{{Template:\-([0-9]++)_([0-9]++)\|?([^{}]++)}}%', function(array $matches): string {
$params = [];
[, $public, $page, $paramStr] = $matches;
$tplPage = $this->repo->getByOwnerAndVID(-1 * $public, (int) $page);
if(!$tplPage)
return "<b>Notice: No template at public$public/$page</b><br/>";
foreach(explode("|", $paramStr) as $kvPair) {
$kvPair = explode("=", $kvPair);
if(sizeof($kvPair) != 2)
continue;
$params[$kvPair[0]] = $kvPair[1];
}
bdump($params);
$parser = new Parser($tplPage, $this->repo, $this->depth, $params, $this->ctx);
return $parser->asHTML();
}, $html);
} else {
return "<b>Notice: Refusing to include template due to high indirection level (6)</b><br/>";
}
}
private function parseTextile(): string
{
return (new Textile\Parser)->parse($this->parseOvkTemplates());
}
private function parseOvkIncludes(): string
{
$html = new \DOMDocument();
$html->loadHTML("<?xml encoding=\"UTF-8\">" . $this->parseTextile());
foreach($html->getElementsByTagName("a") as $link) {
$href = $link->getAttribute("href");
if(preg_match('%^#([a-z]++)(\-?[0-9]++)_([0-9]++)#$%', $href, $matches))
$link->setAttribute("href", $this->resolveEntityURL($matches, true, false) ?? "unknown");
else if(preg_match('%^#([a-z]++)(\-?[0-9]++)#$%', $href, $matches))
$link->setAttribute("href", $this->resolveEntityURL($matches, false, false) ?? "unknown");
}
foreach($html->getElementsByTagName("img") as $pic) {
$src = $pic->getAttribute("src");
if(preg_match('%^#([a-z]++)(\-?[0-9]++)_([0-9]++)#$%', $src, $matches))
$pic->setAttribute("src", $this->resolveEntityURL($matches, true, true) ?? "unknown");
else if(preg_match('%^#([a-z]++)(\-?[0-9]++)#$%', $src, $matches))
$pic->setAttribute("src", $this->resolveEntityURL($matches, false, true) ?? "unknown");
}
return $html->saveHTML();
}
function asHTML(): string
{
$purifier = $this->getPurifier();
return $purifier->purify($this->parseOvkIncludes());
}
}

View file

@ -0,0 +1,39 @@
<?php declare(strict_types=1);
namespace openvk\Web\Models\Entities\WikiPage;
use openvk\Web\Models\Repositories\WikiPages;
use openvk\Web\Models\Entities\Postable;
class WikiPage extends Postable {
protected $tableName = "wikipages";
function getTitle(): string
{
return $this->getRecord()->title;
}
function getSource(): string
{
return $this->getRecord()->source;
}
function getHitCounter(): int
{
return $this->getRecord()->hits;
}
function getText(array $ctx = []): string
{
$counter = 0;
$parser = new Parser($this, new WikiPages, $counter, ["firstInclusion" => "yes"], array_merge([
"time" => time(),
], $ctx));
return $parser->asHTML();
}
function view(): void
{
$this->stateChanges("hits", $this->getRecord()->hits + 1);
$this->save();
}
}

View file

@ -0,0 +1,39 @@
<?php declare(strict_types=1);
namespace openvk\Web\Models\Repositories;
use openvk\Web\Models\Entities\WikiPage;
use Nette\Database\Table\ActiveRow;
use Chandler\Database\DatabaseConnection;
class WikiPages
{
private $context;
private $wp;
function __construct()
{
$this->context = DatabaseConnection::i()->getContext();
$this->wp = $this->context->table("wikipages");
}
private function toWikiPage(?ActiveRow $ar): ?WikiPage
{
return is_null($ar) ? NULL : new WikiPage($ar);
}
function get(int $id): ?WikiPage
{
return $this->toWikiPage($this->wp->get($id));
}
function getByOwnerAndVID(int $owner, int $note): ?WikiPage
{
$wp = (clone $this->wp)->where(['owner' => $owner, 'virtual_id' => $note])->fetch();
return $this->toWikiPage($wp);
}
function getByOwnerAndTitle(int $owner, string $title): ?WikiPage
{
$wp = (clone $this->wp)->where(['owner' => $owner, 'title' => $title])->fetch();
return $this->toWikiPage($wp);
}
}

View file

@ -203,6 +203,7 @@ final class GroupPresenter extends OpenVKPresenter
$club->setAbout(empty($this->postParam("about")) ? NULL : $this->postParam("about"));
$club->setShortcode(empty($this->postParam("shortcode")) ? NULL : $this->postParam("shortcode"));
$club->setWall(empty($this->postParam("wall")) ? 0 : 1);
$club->setWikiEnabled(empty($this->postParam("wiki")) ? false : true);
$club->setAdministrators_List_Display(empty($this->postParam("administrators_list_display")) ? 0 : $this->postParam("administrators_list_display"));
$website = $this->postParam("website") ?? "";

View file

@ -0,0 +1,112 @@
<?php declare(strict_types=1);
namespace openvk\Web\Presenters;
use openvk\Web\Models\Repositories\{Clubs, WikiPages};
use openvk\Web\Models\Entities\WikiPage;
final class WikiPresenter extends OpenVKPresenter
{
private $groups;
private $pages;
function __construct(Clubs $groups, WikiPages $pages)
{
$this->groups = $groups;
$this->pages = $pages;
parent::__construct();
}
function renderView(int $owner, int $page)
{
$page = $this->pages->getByOwnerAndVID((int) $owner, (int) $page);
if(!$page || !$page->getOwner()->containsWiki())
$this->notFound();
$this->template->oURL = $page->getOwner()->getURL();
$this->template->oName = $page->getOwner()->getCanonicalName();
$this->template->title = $page->getTitle();
$this->template->html = $page->getText([
]);
}
function renderSource(int $owner, int $page)
{
$page = $this->pages->getByOwnerAndVID((int) $owner, (int) $page);
if(!$page)
$this->flashFail("err", tr("error"), tr("page_id_invalid"));
$src = $page->getSource();
header("Content-Type: text/plain");
header("Content-Length: " . strlen($src));
header("Pragma: no-cache");
exit($src);
}
function renderEdit()
{
$this->assertUserLoggedIn();
if(is_null($groupId = $this->queryParam("gid")))
$this->flashFail("err", tr("error"), tr("group_id_invalid"));
$group = $this->groups->get((int) $groupId);
if(!$group)
$this->flashFail("err", tr("error"), tr("group_id_invalid"));
else if(!$group->canBeModifiedBy($this->user->identity))
$this->flashFail("err", tr("error"), tr("access_error"));
$page;
$pageId = $this->queryParam("elid");
$title = $this->requestParam("title");
if(!is_null($pageId) && $pageId > 0) {
$page = $this->pages->getByOwnerAndVID($groupId * -1, (int) $pageId);
if(!$page)
$this->flashFail("err", tr("error"), tr("page_id_invalid"));
} else if(!is_null($title)) {
$page = $this->pages->getByOwnerAndTitle($groupId * -1, $title);
} else {
$this->flashFail("err", tr("error"), tr("page_id_invalid"));
}
if($_SERVER["REQUEST_METHOD"] === "POST") {
$this->willExecuteWriteAction();
if($this->postParam("elid") == 0) {
$page = new WikiPage;
$page->setOwner($groupId * -1);
$page->setTitle($title);
}
if($this->postParam("elid") != 0 && $title !== $page->getTitle()) {
if(!is_null($this->pages->getByOwnerAndTitle($groupId * -1, $title)))
$this->flashFail("err", tr("error"), tr("article_already_exists"));
$page->setTitle($title);
}
$page->setSource($this->postParam("source"));
$page->save();
$this->flash("succ", tr("succ"), tr("article_saved"));
$this->redirect("/pages?gid=$groupId&title=" . rawurlencode($title));
return;
}
if(!$page) {
$this->template->form = (object) [
"pId" => 0,
"gId" => $groupId,
"title" => $title ?? "",
"source" => "",
];
} else {
$this->template->form = (object) [
"pId" => $page->getVirtualId(),
"gId" => $groupId,
"title" => $page->getTitle(),
"source" => $page->getSource(),
];
}
}
}

View file

@ -69,7 +69,7 @@
<input type="file" name="ava" accept="image/*" />
</td>
</tr>
<tr>
<tr>
<td width="120" valign="top">
<span class="nobold">{_wall}: </span>
</td>
@ -77,6 +77,14 @@
<input type="checkbox" name="wall" value="1" {if $club->canPost()}checked{/if}/> {_group_allow_post_for_everyone}
</td>
</tr>
<tr>
<td width="120" valign="top">
<span class="nobold">{_wiki}: </span>
</td>
<td>
<input type="checkbox" name="wiki" n:attr="checked => $club->containsWiki()" /> {_enable_wiki}
</td>
</tr>
<tr>
<td width="120" valign="top">
<span class="nobold">{_group_administrators_list}: </span>

View file

@ -41,42 +41,64 @@
</table>
</div>
<div n:if="$club->getFollowersCount() > 0">
{var followersCount = $club->getFollowersCount()}
{var followersCount = $club->getFollowersCount()}
<div class="content_title_expanded" onclick="hidePanel(this, {$followersCount});">
{_participants}
<div class="content_title_expanded" onclick="hidePanel(this, {$followersCount});">
{_participants}
</div>
<div>
<div class="content_subtitle">
{tr("participants", $followersCount)}
<div style="float:right;">
<a href="/club{$club->getId()}/followers">{_all_title}</a>
</div>
</div>
<div>
<div class="content_subtitle">
{tr("participants", $followersCount)}
<div style="float:right;">
<a href="/club{$club->getId()}/followers">{_all_title}</a>
</div>
</div>
<div style="padding-left: 5px;">
<table
n:foreach="$club->getFollowers(1) as $follower"
n:class="User"
style="text-align:center;display:inline-block;width:62px"
cellspacing=4>
<tbody>
<tr>
<td>
<a href="{$follower->getURL()}">
<img src="{$follower->getAvatarUrl()}" width="50" />
</a>
</td>
</tr>
<tr>
<td>
<a href="{$follower->getURL()}">{$follower->getFirstName()}</a>
</td>
</tr>
</tbody>
</table>
</div>
<div style="padding-left: 5px;">
<table
n:foreach="$club->getFollowers(1) as $follower"
n:class="User"
style="text-align:center;display:inline-block;width:62px"
cellspacing='4'>
<tbody>
<tr>
<td>
<a href="{$follower->getURL()}">
<img src="{$follower->getAvatarUrl()}" width="50" />
</a>
</td>
</tr>
<tr>
<td>
<a href="{$follower->getURL()}">{$follower->getFirstName()}</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div n:if="$club->containsWiki()">
{var page = $club->getWikiHomePage()}
<div class="content_title_expanded" onclick="hidePanel(this);">
{$page->getTitle()}
</div>
<div>
<div class="content_subtitle">
{_wiki_page}
<div n:if="$club->canBeModifiedBy($thisUser)" style="float:right;">
<a href="/pages?gid={$club->getId()}&elid=1" target="_blank">{_edit}</a>
</div>
</div>
<div style="padding-left: 5px;">
<article id="userContent" style="overflow-y: scroll; height: 200px;">
{$page->getText()|noescape}
</article>
</div>
</div>
</div>
<br/>
{presenter "openvk!Wall->wallEmbedded", -$club->getId()}

View file

@ -0,0 +1,42 @@
{extends "../@layout.xml"}
{block title}{_create_note}{/block}
{block header}
{_create_note}
{/block}
{block content}
<form id="wikiFactory" method="POST">
<input type="text" name="title" value="{$form->title}" placeholder="{_name_note}" style="width:603px;" />
<br/><br/>
<textarea name="source" style="display:none;"></textarea>
<div id="editor" style="width:600px;height:300px;border:1px solid grey"></div>
<input type="hidden" name="elid" value="{$form->pId}" />
<input type="hidden" name="hash" value="{$csrfToken}" />
<button class="button">{_save}</button>
</form>
{script "js/node_modules/monaco-editor/min/vs/loader.js"}
{script "js/node_modules/requirejs/bin/r.js"}
<script>
require.config({
paths: {
'vs': '/assets/packages/static/openvk/js/node_modules/monaco-editor/min/vs'
}
});
require(['vs/editor/editor.main'], function() {
window._editor = monaco.editor.create(document.getElementById('editor'), {
value: {$form->source},
lineNumbers: "on",
wordWrap: "on",
language: "html"
});
});
document.querySelector("#wikiFactory").addEventListener("submit", function() {
document.querySelector("textarea").value = window._editor.getValue();
});
</script>
{/block}

View file

@ -0,0 +1,15 @@
{extends "../@layout.xml"}
{block title}{$title} - {$oName}{/block}
{block header}
<a href="{$oURL}">{$oName}</a>
»
{$title}
{/block}
{block content}
<article id="userContent" style="min-height: 300pt;">
{$html|noescape}
</article>
{/block}

View file

@ -18,6 +18,7 @@ services:
- openvk\Web\Presenters\SupportPresenter
- openvk\Web\Presenters\AdminPresenter
- openvk\Web\Presenters\GiftsPresenter
- openvk\Web\Presenters\WikiPresenter
- openvk\Web\Presenters\MessengerPresenter
- openvk\Web\Presenters\ThemepacksPresenter
- openvk\Web\Presenters\VKAPIPresenter
@ -36,4 +37,5 @@ services:
- openvk\Web\Models\Repositories\IPs
- openvk\Web\Models\Repositories\Vouchers
- openvk\Web\Models\Repositories\Gifts
- openvk\Web\Models\Repositories\WikiPages
- openvk\Web\Models\Repositories\ContentSearchRepository

View file

@ -215,6 +215,12 @@ routes:
handler: "Gifts->userGifts"
- url: "/gifts"
handler: "Gifts->stub"
- url: "/pages"
handler: "Wiki->edit"
- url: "/page{num}_{num}"
handler: "Wiki->view"
- url: "/page{num}_{num}.textile"
handler: "Wiki->source"
- url: "/admin"
handler: "Admin->index"
- url: "/admin/users"

View file

@ -1598,3 +1598,15 @@ body.scrolled .toTop:hover {
margin: 5px;
border: 1px solid #C0CAD5;
}
article#userContent {
overflow-x: hidden;
}
article#userContent a {
font-weight: 600;
}
article#userContent a.nonexistent {
color: #d90000;
}

View file

@ -234,7 +234,7 @@ return (function() {
define("nullptr", NULL);
define("OPENVK_DEFAULT_INSTANCE_NAME", "OpenVK", false);
define("OPENVK_VERSION", "Altair Preview ($ver)", false);
define("OPENVK_VERSION", "Altair Preview ($ver-wiki)", false);
define("OPENVK_DEFAULT_PER_PAGE", 10, false);
define("__OPENVK_ERROR_CLOCK_IN_FUTURE", "Server clock error: FK1200-DTF", false);
});

View file

@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS `wikipages` (
`id` bigint(20) unsigned PRIMARY KEY NOT NULL AUTO_INCREMENT,
`owner` bigint(20) NOT NULL,
`virtual_id` bigint(20) NOT NULL,
`created` bigint(20) NOT NULL,
`edited` bigint(20) DEFAULT NULL,
`title` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
`source` longtext COLLATE utf8mb4_unicode_ci NOT NULL,
`hits` bigint(20) NOT NULL DEFAULT 0,
`anonymous` tinyint(1) NOT NULL DEFAULT '0',
`deleted` tinyint(4) NOT NULL DEFAULT '0'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE `wikipages` ADD INDEX( `owner`, `virtual_id`);
ALTER TABLE `wikipages` ADD UNIQUE( `owner`, `title`);
ALTER TABLE `groups` ADD `pages` BOOLEAN NOT NULL DEFAULT FALSE AFTER `wall`;