From 1c78a3b0a73e72d34714fa749aff293dbda6b4d2 Mon Sep 17 00:00:00 2001 From: Thomas Hackl Date: Fri, 19 Dec 2025 08:44:03 +0100 Subject: Resolve "Deeplinks und Definition von Kurz-URLs" Closes #5896 Merge request studip/studip!4570 --- app/controllers/course/details.php | 10 +- app/controllers/course/enrolment.php | 29 ++- app/controllers/course/overview.php | 2 +- app/controllers/short_urls.php | 33 +++ app/controllers/u.php | 17 ++ app/views/course/enrolment/apply.php | 3 + composer.json | 3 +- composer.lock | 57 +++++- db/migrations/6.2.3_add_short_urls.php | 34 ++++ lib/classes/Context.php | 59 ++++-- lib/classes/JsonApi/RouteMap.php | 9 + lib/classes/JsonApi/Routes/ShortUrls/Authority.php | 28 +++ .../JsonApi/Routes/ShortUrls/ShortUrlCreate.php | 68 +++++++ .../JsonApi/Routes/ShortUrls/ShortUrlDelete.php | 38 ++++ .../JsonApi/Routes/ShortUrls/ShortUrlShow.php | 20 ++ .../JsonApi/Routes/ShortUrls/ShortUrlUpdate.php | 52 +++++ lib/classes/JsonApi/SchemaMap.php | 3 + lib/classes/JsonApi/Schemas/ShortUrl.php | 61 ++++++ lib/models/ShortUrl.php | 28 +++ lib/navigation/ContentsNavigation.php | 12 ++ lib/navigation/FooterNavigation.php | 10 + lib/plugins/engine/PluginEngine.php | 16 +- package-lock.json | 19 +- package.json | 2 + resources/assets/stylesheets/scss/header.scss | 1 + resources/assets/stylesheets/scss/responsive.scss | 6 +- resources/vue/apps/short-urls/ShortUrl.vue | 84 ++++++++ resources/vue/apps/short-urls/ShortUrlLink.vue | 112 ++++++++++ resources/vue/apps/short-urls/ShortUrlList.vue | 226 +++++++++++++++++++++ resources/vue/directives/autofocus.ts | 4 +- resources/vue/store/pinia/shortUrlsStore.js | 61 ++++++ templates/footer.php | 46 +++-- templates/header.php | 11 + 33 files changed, 1109 insertions(+), 55 deletions(-) create mode 100644 app/controllers/short_urls.php create mode 100644 app/controllers/u.php create mode 100644 db/migrations/6.2.3_add_short_urls.php create mode 100644 lib/classes/JsonApi/Routes/ShortUrls/Authority.php create mode 100644 lib/classes/JsonApi/Routes/ShortUrls/ShortUrlCreate.php create mode 100644 lib/classes/JsonApi/Routes/ShortUrls/ShortUrlDelete.php create mode 100644 lib/classes/JsonApi/Routes/ShortUrls/ShortUrlShow.php create mode 100644 lib/classes/JsonApi/Routes/ShortUrls/ShortUrlUpdate.php create mode 100644 lib/classes/JsonApi/Schemas/ShortUrl.php create mode 100644 lib/models/ShortUrl.php create mode 100644 resources/vue/apps/short-urls/ShortUrl.vue create mode 100644 resources/vue/apps/short-urls/ShortUrlLink.vue create mode 100644 resources/vue/apps/short-urls/ShortUrlList.vue create mode 100644 resources/vue/store/pinia/shortUrlsStore.js diff --git a/app/controllers/course/details.php b/app/controllers/course/details.php index d8ac254..969c34d 100644 --- a/app/controllers/course/details.php +++ b/app/controllers/course/details.php @@ -223,16 +223,22 @@ class Course_DetailsController extends AuthenticatedController PageLayout::postInfo(_('Die Anmeldung ist verbindlich, Teilnehmende können sich nicht selbst austragen.')); } } + + $params = []; + if (Request::int('from_short_url')) { + $params['from_short_url'] = Request::int('from_short_url'); + } + $links->addLink( $abo_msg, - $this->url_for("course/enrolment/apply/{$this->course->id}"), + $this->url_for("course/enrolment/apply/{$this->course->id}", $params), Icon::create('door-enter'), ['data-dialog' => 'size=big'] ); $this->links[] = [ 'label' => $abo_msg, - 'url' => $this->url_for("course/enrolment/apply/{$this->course->id}"), + 'url' => $this->url_for("course/enrolment/apply/{$this->course->id}", $params), 'attributes' => ['data-dialog' => 'size=big'], ]; diff --git a/app/controllers/course/enrolment.php b/app/controllers/course/enrolment.php index b3c7ee3..49d547d 100644 --- a/app/controllers/course/enrolment.php +++ b/app/controllers/course/enrolment.php @@ -49,7 +49,17 @@ class Course_EnrolmentController extends AuthenticatedController || ($enrolment_info->getCodeword() === 'free_access' && !User::findCurrent()) ) ) { - $redirect_url = URLHelper::getUrl('dispatch.php/course/go', ['to' => $this->course_id]); + if (Request::int('from_short_url')) { + $link = ShortUrl::find(Request::int('from_short_url')); + if ($link) { + $redirect_url = URLHelper::getUrl($link->path); + } else { + $redirect_url = URLHelper::getUrl('dispatch.php/course/go', ['to' => $this->course_id]); + } + } else { + $redirect_url = URLHelper::getUrl('dispatch.php/course/go', ['to' => $this->course_id]); + } + if (Request::isXhr()) { $this->response->add_header('X-Location', $redirect_url); $this->render_nothing(); @@ -252,16 +262,29 @@ class Course_EnrolmentController extends AuthenticatedController if (!empty($course) && $course->admission_prelim) { $this->relocate(URLHelper::getLink('dispatch.php/course/details', ['sem_id' => $this->course_id])); } else { - $this->relocate(URLHelper::getLink('dispatch.php/course/go', ['to' => $this->course_id])); + if (Request::int('from_short_url')) { + $url = ShortUrl::find(Request::int('from_short_url')); + + if ($url) { + $this->relocate(URLHelper::getUrl($url->path)); + } + } else { + $this->relocate(URLHelper::getLink('dispatch.php/course/go', ['to' => $this->course->id])); + } } } elseif ($enrol_user) { + $params = ['apply' => 1]; + if (Request::int('from_short_url')) { + $params['from_short_url'] = Request::int('from_short_url'); + } + PageLayout::postQuestion( sprintf( _('Wollen Sie sich zu der Veranstaltung "%s" wirklich anmelden?'), htmlReady(Course::find($this->course_id)->name) ), - $this->action_url("apply/{$this->course_id}", ['apply' => 1]), + $this->action_url("apply/{$this->course_id}", $params), $this->action_url("apply/{$this->course_id}", ['decline' => 1]) ); diff --git a/app/controllers/course/overview.php b/app/controllers/course/overview.php index 4313cdc..fc0f441 100644 --- a/app/controllers/course/overview.php +++ b/app/controllers/course/overview.php @@ -109,7 +109,7 @@ class Course_OverviewController extends AuthenticatedController } $connections = StudygroupCourse::countBySql( - "`studygroup_id` = :cid OR `course_id` = :cid", + "`studygroup_id` = :cid OR `course_id` = :cid", [ 'cid' => $this->course_id ] diff --git a/app/controllers/short_urls.php b/app/controllers/short_urls.php new file mode 100644 index 0000000..e9fa843 --- /dev/null +++ b/app/controllers/short_urls.php @@ -0,0 +1,33 @@ +current_user = User::findCurrent(); + } + + public function index_action(): void + { + PageLayout::setTitle(_('Meine Kurzlinks')); + Navigation::activateItem('/contents/short_urls/overview'); + + $this->render_vue_app( + Studip\VueApp::create('short-urls/ShortUrlList') + ->withStore('shortUrlsStore', 'useShortUrlsStore') + ); + } + + public function create_action(): void + { + PageLayout::setTitle(_('Link zur aktuellen Seite erstellen')); + + $this->render_vue_app( + Studip\VueApp::create('short-urls/ShortUrlLink') + ->withProps(['isInContext' => Context::isCourse() && Context::get()->hasCourseSet()]) + ); + } +} diff --git a/app/controllers/u.php b/app/controllers/u.php new file mode 100644 index 0000000..76cd35e --- /dev/null +++ b/app/controllers/u.php @@ -0,0 +1,17 @@ +redirect($this->url_for('start')); + return; + } + + $this->redirect(URLHelper::getURL($short_url->path, ['from_short_url' => $short_url->id])); + } +} diff --git a/app/views/course/enrolment/apply.php b/app/views/course/enrolment/apply.php index 6e1f910..d890f8a 100644 --- a/app/views/course/enrolment/apply.php +++ b/app/views/course/enrolment/apply.php @@ -11,6 +11,9 @@
" method="post"> + + +
'size=big']) ?> diff --git a/composer.json b/composer.json index a7dfd07..cbc0831 100644 --- a/composer.json +++ b/composer.json @@ -138,7 +138,8 @@ "oat-sa/lib-lti1p3-deep-linking": "4.1.0", "lcobucci/jwt": "^4.3", "guzzlehttp/guzzle": "^7.9.2", - "phpoffice/phpword": "^1.4" + "phpoffice/phpword": "^1.4", + "sqids/sqids": "^0.5.0" }, "replace": { "symfony/polyfill-php54": "*", diff --git a/composer.lock b/composer.lock index 40facba..6499159 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c0e4cb48905267aa9a02647b5c741d74", + "content-hash": "376cb58271d3c6398ec30f3adf8253c8", "packages": [ { "name": "algo26-matthias/idna-convert", @@ -5126,6 +5126,61 @@ "time": "2024-06-12T11:22:32+00:00" }, { + "name": "sqids/sqids", + "version": "0.5.0", + "source": { + "type": "git", + "url": "https://github.com/sqids/sqids-php.git", + "reference": "d594b87134f581578422caca005bc7ec99f958b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sqids/sqids-php/zipball/d594b87134f581578422caca005bc7ec99f958b2", + "reference": "d594b87134f581578422caca005bc7ec99f958b2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.5|^11.2" + }, + "suggest": { + "ext-bcmath": "Required to use BC Math arbitrary precision mathematics (*).", + "ext-gmp": "Required to use GNU multiple precision mathematics (*)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "Sqids\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Generate short YouTube-looking IDs from numbers", + "homepage": "https://sqids.org/php", + "keywords": [ + "decode", + "encode", + "generate", + "hashids", + "ids", + "sqids" + ], + "support": { + "issues": "https://github.com/sqids/sqids-php/issues", + "source": "https://github.com/sqids/sqids-php/tree/0.5.0" + }, + "time": "2025-01-05T05:37:48+00:00" + }, + { "name": "symfony/clock", "version": "v6.4.13", "source": { diff --git a/db/migrations/6.2.3_add_short_urls.php b/db/migrations/6.2.3_add_short_urls.php new file mode 100644 index 0000000..eea9548 --- /dev/null +++ b/db/migrations/6.2.3_add_short_urls.php @@ -0,0 +1,34 @@ +exec( + "CREATE TABLE IF NOT EXISTS short_urls ( + id INT PRIMARY KEY NOT NULL AUTO_INCREMENT, + alias VARCHAR(255) UNIQUE NOT NULL, + path VARCHAR(255) NOT NULL, + title VARCHAR(255) NOT NULL, + user_id VARCHAR(32) COLLATE latin1_bin NOT NULL, + mkdate INT(11) UNSIGNED NOT NULL, + chdate INT(11) UNSIGNED NOT NULL + )" + ); + } + + + public function down() + { + DBManager::get()->exec("DROP TABLE short_urls"); + } +} diff --git a/lib/classes/Context.php b/lib/classes/Context.php index 1bc2623..562d612 100644 --- a/lib/classes/Context.php +++ b/lib/classes/Context.php @@ -204,8 +204,6 @@ class Context */ public static function set($id) { - global $perm; - self::close(); self::loadContext($id); @@ -219,32 +217,47 @@ class Context URLHelper::addLinkParam('cid', $GLOBALS['SessionSeminar']); - if (self::isCourse()) { - $course = self::get(); + $context = self::get(); + if ($context instanceof Course) { // check if current user can access the object - if (!$perm->get_studip_perm($course['Seminar_id'])) { - if ($course['lesezugriff'] > 0 || !Config::get()->ENABLE_FREE_ACCESS) { + if (!$GLOBALS['perm']->get_studip_perm($context->id)) { + if ($context->lesezugriff > 0 || !Config::get()->ENABLE_FREE_ACCESS) { // redirect to login page if user is not logged in if (!User::findCurrent()) { throw new LoginException(); } - if (!$perm->get_studip_perm($course['Seminar_id'])) { - throw new AccessDeniedException(); + if ( + Request::int('from_short_url') + && !$GLOBALS['perm']->get_studip_perm($context->id) + && !match_route('dispatch.php/course/details') + ) { + PageLayout::postWarning(_('Sie sind noch nicht eingetragen. Bitte prüfen Sie eventuell geltende Anmelderegeln!')); + $url = URLHelper::getURL( + 'dispatch.php/course/details', + [ + 'sem_id' => $context->id, + 'from_short_url' => Request::int('from_short_url') + ], + true + ); + header('Location: ' . $url); + die; } } } // if the aux data is forced for this seminar forward all user that havent made an input to this site - if ($course['aux_lock_rule_forced'] - && !$perm->have_studip_perm('tutor', $course['Seminar_id']) + if ( + $context->aux_lock_rule_forced + && !$GLOBALS['perm']->have_studip_perm('tutor', $context->id) && !match_route('dispatch.php/course/members/additional_input') - && !match_route('dispatch.php/course/change_view/*')) - { + && !match_route('dispatch.php/course/change_view/*') + ) { $count = DatafieldEntryModel::countBySql( 'range_id = ? AND sec_range_id = ?', - [$GLOBALS['user']->id, $course['Seminar_id']] + [$GLOBALS['user']->id, $context->id] ); if (!$count) { header('Location: ' . URLHelper::getURL('dispatch.php/course/members/additional_input')); @@ -252,21 +265,35 @@ class Context die; } } - } else if (self::isInstitute()) { + } else if ($context instanceof Institute) { // check if current user can access the object $no_access = (!Config::get()->ENABLE_FREE_ACCESS || (Config::get()->ENABLE_FREE_ACCESS == 'courses_only')) - && !$perm->have_perm('user'); + && !$GLOBALS['perm']->have_perm('user'); + if ($no_access) { // redirect to login page if user is not logged in if (!User::findCurrent()) { throw new LoginException(); } - if (!$perm->have_perm('user')) { + if (!$GLOBALS['perm']->have_perm('user')) { throw new AccessDeniedException(); } } + + if ( + !$GLOBALS['perm']->get_studip_perm($context->id) + && !match_route('dispatch.php/institute/overview') + ) { + PageLayout::postWarning(_('Sie sind dieser Einrichtung nicht zugeordnet!')); + $url = URLHelper::getURL( + 'dispatch.php/institute/overview', + ['cid' => $context->id] + ); + header('Location: ' . $url); + die; + } } } diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index 40b535d..f3e97c0 100644 --- a/lib/classes/JsonApi/RouteMap.php +++ b/lib/classes/JsonApi/RouteMap.php @@ -143,6 +143,7 @@ class RouteMap $this->addAuthenticatedThemesRoutes($group); $this->addAuthenticatedUserFilterRoutes($group); $this->addAuthenticatedWikiRoutes($group); + $this->addAuthenticatedShortUrlRoutes($group); } /** @@ -805,6 +806,14 @@ class RouteMap } + private function addAuthenticatedShortUrlRoutes(RouteCollectorProxy $group): void + { + $group->get('/short-urls', Routes\ShortUrls\ShortUrlShow::class); + $group->post('/short-urls', Routes\ShortUrls\ShortUrlCreate::class); + $group->patch('/short-urls/{id}', Routes\ShortUrls\ShortUrlUpdate::class); + $group->delete('/short-urls/{id}', Routes\ShortUrls\ShortUrlDelete::class); + } + private function addRelationship(RouteCollectorProxy $group, string $url, string $handler): void { $group->map(['GET', 'PATCH', 'POST', 'DELETE'], $url, $handler); diff --git a/lib/classes/JsonApi/Routes/ShortUrls/Authority.php b/lib/classes/JsonApi/Routes/ShortUrls/Authority.php new file mode 100644 index 0000000..2a39430 --- /dev/null +++ b/lib/classes/JsonApi/Routes/ShortUrls/Authority.php @@ -0,0 +1,28 @@ +id === $short_url->user_id + || $user->perms === 'root'; + } + + public static function canUpdateShortUrl(User $user, \ShortUrl $short_url): bool + { + return self::canAccessShortUrl($user, $short_url); + } + + public static function canDeleteShortUrl(User $user, \ShortUrl $short_url): bool + { + return self::canUpdateShortUrl($user, $short_url); + } +} diff --git a/lib/classes/JsonApi/Routes/ShortUrls/ShortUrlCreate.php b/lib/classes/JsonApi/Routes/ShortUrls/ShortUrlCreate.php new file mode 100644 index 0000000..ea51cc8 --- /dev/null +++ b/lib/classes/JsonApi/Routes/ShortUrls/ShortUrlCreate.php @@ -0,0 +1,68 @@ +getUser($request); + + if (!Authority::canCreateShortUrl($user)) { + throw new AuthorizationFailedException(); + } + + $json = $this->validate($request, $args); + + $short_url = ShortUrl::findOneBySQL( + '`path` = ? AND `user_id` = ?', + [$json['data']['attributes']['path'], $user->id] + ); + + if ($short_url) { + return $this->getContentResponse($short_url); + } + + if (\ShortUrl::countBySql('alias = ?', [ $json['data']['attributes']['alias']])) { + throw new ConflictException(_('Der verwendete Alias existiert bereits.')); + } + + $short_url = \ShortUrl::create([ + 'path' => $json['data']['attributes']['path'], + 'alias' => $json['data']['attributes']['alias'], + 'title' => $json['data']['attributes']['title'], + 'user_id' => $user->id, + ]); + + return $this->getContentResponse($short_url); + } + + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data.attributes.path')) { + return 'No url for the short-url defined'; + } + + if (!trim(self::arrayGet($json, 'data.attributes.alias'))) { + return 'No alias for the short-url defined'; + } + + if (!self::arrayHas($json, 'data.attributes.title')) { + return 'No title for the link target defined'; + } + + return null; + } +} diff --git a/lib/classes/JsonApi/Routes/ShortUrls/ShortUrlDelete.php b/lib/classes/JsonApi/Routes/ShortUrls/ShortUrlDelete.php new file mode 100644 index 0000000..7578e76 --- /dev/null +++ b/lib/classes/JsonApi/Routes/ShortUrls/ShortUrlDelete.php @@ -0,0 +1,38 @@ +getUser($request); + + if (!Authority::canDeleteShortUrl($user, $short_url)) { + throw new AuthorizationFailedException(); + } + + $short_url->delete(); + + return $this->getCodeResponse(204); + } + +} diff --git a/lib/classes/JsonApi/Routes/ShortUrls/ShortUrlShow.php b/lib/classes/JsonApi/Routes/ShortUrls/ShortUrlShow.php new file mode 100644 index 0000000..a6f784a --- /dev/null +++ b/lib/classes/JsonApi/Routes/ShortUrls/ShortUrlShow.php @@ -0,0 +1,20 @@ +getUser($request); + + $short_urls = \ShortUrl::findBySql('user_id = ? ORDER BY `alias` ASC', [$user->id]); + + return $this->getContentResponse($short_urls); + } +} diff --git a/lib/classes/JsonApi/Routes/ShortUrls/ShortUrlUpdate.php b/lib/classes/JsonApi/Routes/ShortUrls/ShortUrlUpdate.php new file mode 100644 index 0000000..4e480bd --- /dev/null +++ b/lib/classes/JsonApi/Routes/ShortUrls/ShortUrlUpdate.php @@ -0,0 +1,52 @@ +getUser($request); + + $short_url = \ShortUrl::find($args['id']); + + if (!$short_url) { + throw new RecordNotFoundException(); + } + + if (!Authority::canUpdateShortUrl($user, $short_url)) { + throw new AuthorizationFailedException(); + } + + $json = $this->validate($request); + + $short_url->alias = $json['data']['attributes']['alias']; + $short_url->title = $json['data']['attributes']['title']; + $short_url->store(); + + return $this->getContentResponse($short_url); + } + + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data.attributes.alias')) { + return 'No alias for the short-url defined'; + } + + if (!self::arrayHas($json, 'data.attributes.title')) { + return 'No title for the link target defined'; + } + + return null; + } +} diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php index d45c6a7..7432abc 100644 --- a/lib/classes/JsonApi/SchemaMap.php +++ b/lib/classes/JsonApi/SchemaMap.php @@ -2,6 +2,8 @@ namespace JsonApi; +use JsonApi\Schemas\ShortUrl; + /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ @@ -102,6 +104,7 @@ class SchemaMap \Courseware\Unit::class => Schemas\Courseware\Unit::class, \Courseware\UserDataField::class => Schemas\Courseware\UserDataField::class, \Courseware\UserProgress::class => Schemas\Courseware\UserProgress::class, + \ShortUrl::class => Schemas\ShortUrl::class, ]; } } diff --git a/lib/classes/JsonApi/Schemas/ShortUrl.php b/lib/classes/JsonApi/Schemas/ShortUrl.php new file mode 100644 index 0000000..ff7e90b --- /dev/null +++ b/lib/classes/JsonApi/Schemas/ShortUrl.php @@ -0,0 +1,61 @@ +id; + } + + /** + * @param \ShortUrl $resource + */ + public function getAttributes($resource, ContextInterface $context): iterable + { + return [ + 'alias' => $resource->alias, + 'path' => $resource->path, + 'title' => $resource->title, + 'mkdate' => date('c', $resource->mkdate), + 'chdate' => date('c', $resource->chdate), + ]; + } + + /** + * @param \ShortUrl $resource + */ + public function getRelationships($resource, ContextInterface $context): iterable + { + $relationships = []; + + $isPrimary = $context->getPosition()->getLevel() === 0; + if ($isPrimary) { + $relationships = $this->getUserRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_USER)); + } + + return $relationships; + } + + private function getUserRelationship(array $relationships, \ShortUrl $short_url, bool $includeData): array + { + $relationships[self::REL_USER] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($short_url->user), + ], + self::RELATIONSHIP_DATA => $includeData ? $short_url->user : \User::build(['id' => $short_url->user_id], false), + ]; + + return $relationships; + } +} diff --git a/lib/models/ShortUrl.php b/lib/models/ShortUrl.php new file mode 100644 index 0000000..a9e29a1 --- /dev/null +++ b/lib/models/ShortUrl.php @@ -0,0 +1,28 @@ + User::class, + 'foreign_key' => 'user_id', + ]; + + parent::configure($config); + } +} diff --git a/lib/navigation/ContentsNavigation.php b/lib/navigation/ContentsNavigation.php index 0d75b3f..7dd1ddd 100644 --- a/lib/navigation/ContentsNavigation.php +++ b/lib/navigation/ContentsNavigation.php @@ -135,5 +135,17 @@ class ContentsNavigation extends Navigation $help->addSubNavigation('help_content', new Navigation(_('Hilfe-Texte'), 'dispatch.php/help_content/admin_overview')); } + $short_urls = new Navigation(_('Kurzlinks'), 'dispatch.php/short_urls/index'); + $short_urls->setImage(Icon::create('group')); + $short_urls->setDescription(_('Verwaltung Ihrer Kurzlinks')); + + $sub_nav = new Navigation( + _('Übersicht'), + 'dispatch.php/short_urls/index' + ); + $short_urls->addSubNavigation('overview', $sub_nav); + + $this->addSubNavigation('short_urls', $short_urls); + } } diff --git a/lib/navigation/FooterNavigation.php b/lib/navigation/FooterNavigation.php index 9a7b9ae..34a2355 100644 --- a/lib/navigation/FooterNavigation.php +++ b/lib/navigation/FooterNavigation.php @@ -27,6 +27,16 @@ class FooterNavigation extends Navigation { parent::initSubNavigation(); + // Permalink to this site + if (is_object($GLOBALS['user']) && $GLOBALS['user']->id !== 'nobody') { + $nav = new Navigation(_('Link zur Seite'), 'dispatch.php/short_urls/create'); + $nav->setLinkAttributes(['id' => 'dummy-create-short-url']); + $this->addSubNavigation( + 'short_url', + $nav + ); + } + // imprint $this->addSubNavigation('siteinfo', new Navigation(_('Impressum'), 'dispatch.php/siteinfo/show?cancel_login=1')); diff --git a/lib/plugins/engine/PluginEngine.php b/lib/plugins/engine/PluginEngine.php index 52f94af..599b4f2 100644 --- a/lib/plugins/engine/PluginEngine.php +++ b/lib/plugins/engine/PluginEngine.php @@ -42,15 +42,23 @@ class PluginEngine // load homepage plugins self::getPlugins(HomepagePlugin::class); + $context_id = Context::getId(); // load course plugins - if (Context::getId()) { - $modules = self::getPlugins(StudipModule::class, Context::getId()); + if ($context_id) { + $modules = self::getPlugins(StudipModule::class, $context_id); $navigation = Navigation::getItem('/course'); foreach ($modules as $module) { - $tabs = $module->getTabNavigation(Context::getId()); + $tabs = $module->getTabNavigation($context_id); - if ($navigation && $tabs) { + if (!$tabs || !$navigation) { + continue; + } + + $has_perm = $GLOBALS['perm']->get_studip_perm($context_id); + $is_core = $module instanceof CoreOverview; + + if ($has_perm || (!$has_perm && $is_core)) { $navigation->addToolNavigation($module->getPluginId(), $tabs); } } diff --git a/package-lock.json b/package-lock.json index ca1b69c..cf565df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "license": "GPL-2.0", "dependencies": { "@vojtechlanka/vue-tags-input": "^3.1.1", - "jsonapi-serializer": "^3.6.9" + "jsonapi-serializer": "^3.6.9", + "qrcode.vue": "^3.6.0", + "sqids": "^0.3.0" }, "devDependencies": { "@axe-core/playwright": "^4.6.1", @@ -11522,6 +11524,15 @@ ], "license": "MIT" }, + "node_modules/qrcode.vue": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/qrcode.vue/-/qrcode.vue-3.6.0.tgz", + "integrity": "sha512-vQcl2fyHYHMjDO1GguCldJxepq2izQjBkDEEu9NENgfVKP6mv/e2SU62WbqYHGwTgWXLhxZ1NCD1dAZKHQq1fg==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/qs": { "version": "6.13.0", "dev": true, @@ -12447,6 +12458,12 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/sqids": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/sqids/-/sqids-0.3.0.tgz", + "integrity": "sha512-lOQK1ucVg+W6n3FhRwwSeUijxe93b51Bfz5PMRMihVf1iVkl82ePQG7V5vwrhzB11v0NtsR25PSZRGiSomJaJw==", + "license": "MIT" + }, "node_modules/stack-utils": { "version": "2.0.6", "dev": true, diff --git a/package.json b/package.json index a86f219..965531f 100644 --- a/package.json +++ b/package.json @@ -100,12 +100,14 @@ "postcss": "^8.4.49", "postcss-loader": "^8.1.1", "postcss-scss": "^4.0.4", + "qrcode.vue": "^3.6.0", "raw-loader": "^4.0.2", "sanitize-html": "^2.7.0", "sass": "^1.29.0", "sass-loader": "^16.0.4", "select2": "4.0.13", "sprintf-js": "^1.0.3", + "sqids": "^0.3.0", "stream-browserify": "^3.0.0", "style-loader": "^4.0.0", "stylelint": "^15.11.0", diff --git a/resources/assets/stylesheets/scss/header.scss b/resources/assets/stylesheets/scss/header.scss index b06c31f..0a9d977 100644 --- a/resources/assets/stylesheets/scss/header.scss +++ b/resources/assets/stylesheets/scss/header.scss @@ -36,6 +36,7 @@ #header-links { flex: 0 1 auto; + justify-items: flex-end; justify-self: flex-end; > ul { diff --git a/resources/assets/stylesheets/scss/responsive.scss b/resources/assets/stylesheets/scss/responsive.scss index 56acc3c..e72ce27 100644 --- a/resources/assets/stylesheets/scss/responsive.scss +++ b/resources/assets/stylesheets/scss/responsive.scss @@ -304,7 +304,7 @@ $sidebarOut: -330px; #header-links { > ul { - > li:not(#responsive-toggle-fullscreen):not(#responsive-toggle-focusmode):not(.helpbar-container) { + > li:not(#responsive-toggle-fullscreen):not(#responsive-toggle-focusmode):not(.helpbar-container):not(#responsive-create-shortlink) { display: none; } @@ -876,10 +876,6 @@ html:not(.responsive-display):not(.fullscreen-mode) { } - #header-links { - display: none; - } - #background-desktop { display: none; } diff --git a/resources/vue/apps/short-urls/ShortUrl.vue b/resources/vue/apps/short-urls/ShortUrl.vue new file mode 100644 index 0000000..953ae37 --- /dev/null +++ b/resources/vue/apps/short-urls/ShortUrl.vue @@ -0,0 +1,84 @@ + + + diff --git a/resources/vue/apps/short-urls/ShortUrlLink.vue b/resources/vue/apps/short-urls/ShortUrlLink.vue new file mode 100644 index 0000000..f2a168a --- /dev/null +++ b/resources/vue/apps/short-urls/ShortUrlLink.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/resources/vue/apps/short-urls/ShortUrlList.vue b/resources/vue/apps/short-urls/ShortUrlList.vue new file mode 100644 index 0000000..a575772 --- /dev/null +++ b/resources/vue/apps/short-urls/ShortUrlList.vue @@ -0,0 +1,226 @@ + + + + + diff --git a/resources/vue/directives/autofocus.ts b/resources/vue/directives/autofocus.ts index 5995629..e4c3d3f 100644 --- a/resources/vue/directives/autofocus.ts +++ b/resources/vue/directives/autofocus.ts @@ -1,13 +1,13 @@ // Shamelessly copied from https://github.com/byteboomers/vue-autofocus-directive -import {DirectiveBinding} from "vue"; +import {DirectiveBinding, nextTick} from "vue"; function focusElement(el: HTMLElement, binding: DirectiveBinding) : void { if (binding.value !== undefined && !binding.value) { return; } - el.focus() + nextTick().then(() => el.focus()); } export default { diff --git a/resources/vue/store/pinia/shortUrlsStore.js b/resources/vue/store/pinia/shortUrlsStore.js new file mode 100644 index 0000000..6b972c2 --- /dev/null +++ b/resources/vue/store/pinia/shortUrlsStore.js @@ -0,0 +1,61 @@ +import {defineStore} from 'pinia'; +import {ref} from 'vue'; +import {$gettext} from '../../../assets/javascripts/lib/gettext'; + +export const useShortUrlsStore = defineStore('shortUrls', () => { + let shortUrls = ref([]); + + async function initialize() { + await STUDIP.jsonapi.withPromises().get('short-urls') + .then(response => { + shortUrls.value = response.data; + }) + .catch(error => STUDIP.Report.error($gettext('Fehler beim Laden der Kurzlinks'), error)); + } + + function getShortUrls() { + return shortUrls.value; + } + + function getShortUrl(id) { + return shortUrls.value.find(item => item.id === id); + } + + function storeShortUrl(shortUrl) { + const index = shortUrls.value.findIndex(item => item.id === shortUrl.id); + + // Not found in store, create a new entry. + if (index === -1) { + STUDIP.jsonapi.withPromises().post('short-urls', {data: {data: shortUrl}}) + .then(response => { + shortUrls.value.push(response.data) + STUDIP.Report.success($gettext('Der Kurzlink wurde gespeichert.')); + }) + .catch(error => STUDIP.Report.error($gettext('Fehler beim Erstellen des Kurzlinks'), error)); + + } else { + STUDIP.jsonapi.withPromises().patch(`short-urls/${shortUrl.id}`, {data: {data: shortUrl}}) + .then(response => { + shortUrls.value[index] = response.data; + STUDIP.Report.success($gettext('Der Kurzlink wurde gespeichert.')); + }) + .catch(error => { + STUDIP.Report.error($gettext('Fehler beim Speichern des Kurzlinks'), error) + }); + } + } + + function deleteShortUrl(id) { + STUDIP.jsonapi.withPromises().delete(`short-urls/${id}`) + .then(() => shortUrls.value.splice(shortUrls.value.findIndex(item => item.id === id), 1)) + .catch(error => STUDIP.Report.error($gettext('Fehler beim Löschen des Kurzlinks'), error)); + } + + return { + initialize, + getShortUrl, + getShortUrls, + storeShortUrl, + deleteShortUrl + }; +}); diff --git a/templates/footer.php b/templates/footer.php index 37389c6..77c406a 100644 --- a/templates/footer.php +++ b/templates/footer.php @@ -1,33 +1,41 @@ - - -