aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorskjnldsv <skjnldsv@protonmail.com>2024-11-13 09:42:26 +0100
committerskjnldsv <skjnldsv@protonmail.com>2024-11-14 10:25:02 +0100
commitb15fdfd40e4bf96b53f7afcf0e4dce359158cf1c (patch)
tree0c5cfe660bcdb04338661687b3733ae3cbc88ced /apps
parentdfa7e7edea66c37a7b33965ad9e93648d44243b0 (diff)
downloadnextcloud-server-b15fdfd40e4bf96b53f7afcf0e4dce359158cf1c.tar.gz
nextcloud-server-b15fdfd40e4bf96b53f7afcf0e4dce359158cf1c.zip
chore(profile): move profile app from core to apps
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
Diffstat (limited to 'apps')
-rw-r--r--apps/dav/lib/CardDAV/Converter.php2
-rw-r--r--apps/profile/appinfo/info.xml21
-rw-r--r--apps/profile/composer/autoload.php25
-rw-r--r--apps/profile/composer/composer.json13
-rw-r--r--apps/profile/composer/composer.lock18
-rw-r--r--apps/profile/composer/composer/ClassLoader.php579
-rw-r--r--apps/profile/composer/composer/InstalledVersions.php359
-rw-r--r--apps/profile/composer/composer/LICENSE21
-rw-r--r--apps/profile/composer/composer/autoload_classmap.php11
-rw-r--r--apps/profile/composer/composer/autoload_namespaces.php9
-rw-r--r--apps/profile/composer/composer/autoload_psr4.php10
-rw-r--r--apps/profile/composer/composer/autoload_real.php37
-rw-r--r--apps/profile/composer/composer/autoload_static.php37
-rw-r--r--apps/profile/composer/composer/installed.json5
-rw-r--r--apps/profile/composer/composer/installed.php23
-rw-r--r--apps/profile/lib/Controller/ProfilePageController.php115
-rw-r--r--apps/profile/src/main.ts27
-rw-r--r--apps/profile/src/services/ProfileSections.ts25
-rw-r--r--apps/profile/src/views/Profile.vue492
-rw-r--r--apps/profile/templates/404-profile.php30
-rw-r--r--apps/profile/templates/profile.php7
-rw-r--r--apps/profile/tests/Controller/ProfilePageControllerTest.php78
-rw-r--r--apps/user_status/lib/Dashboard/UserStatusWidget.php2
23 files changed, 1944 insertions, 2 deletions
diff --git a/apps/dav/lib/CardDAV/Converter.php b/apps/dav/lib/CardDAV/Converter.php
index 64c1cfa66d5..30dba99839e 100644
--- a/apps/dav/lib/CardDAV/Converter.php
+++ b/apps/dav/lib/CardDAV/Converter.php
@@ -76,7 +76,7 @@ class Converter {
new Text(
$vCard,
'X-SOCIALPROFILE',
- $this->urlGenerator->linkToRouteAbsolute('core.ProfilePage.index', ['targetUserId' => $user->getUID()]),
+ $this->urlGenerator->linkToRouteAbsolute('profile.ProfilePage.index', ['targetUserId' => $user->getUID()]),
[
'TYPE' => 'NEXTCLOUD',
'X-NC-SCOPE' => IAccountManager::SCOPE_PUBLISHED
diff --git a/apps/profile/appinfo/info.xml b/apps/profile/appinfo/info.xml
new file mode 100644
index 00000000000..6a192119285
--- /dev/null
+++ b/apps/profile/appinfo/info.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0"?>
+<!--
+ - SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-only
+-->
+<info xmlns:xsi= "http://www.w3.org/2001/XMLSchema-instance"
+ xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
+ <id>profile</id>
+ <name>Profile</name>
+ <summary>This application provides the profile</summary>
+ <description>Provides a customisable user profile interface.</description>
+ <version>1.0.0</version>
+ <licence>agpl</licence>
+ <author>Chris Ng</author>
+ <namespace>Profile</namespace>
+ <category>social</category>
+ <bugs>https://github.com/nextcloud/server/issues</bugs>
+ <dependencies>
+ <nextcloud min-version="31" max-version="31"/>
+ </dependencies>
+</info>
diff --git a/apps/profile/composer/autoload.php b/apps/profile/composer/autoload.php
new file mode 100644
index 00000000000..8fc3e68f25f
--- /dev/null
+++ b/apps/profile/composer/autoload.php
@@ -0,0 +1,25 @@
+<?php
+
+// autoload.php @generated by Composer
+
+if (PHP_VERSION_ID < 50600) {
+ if (!headers_sent()) {
+ header('HTTP/1.1 500 Internal Server Error');
+ }
+ $err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
+ if (!ini_get('display_errors')) {
+ if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
+ fwrite(STDERR, $err);
+ } elseif (!headers_sent()) {
+ echo $err;
+ }
+ }
+ trigger_error(
+ $err,
+ E_USER_ERROR
+ );
+}
+
+require_once __DIR__ . '/composer/autoload_real.php';
+
+return ComposerAutoloaderInitProfile::getLoader();
diff --git a/apps/profile/composer/composer.json b/apps/profile/composer/composer.json
new file mode 100644
index 00000000000..1e09baee734
--- /dev/null
+++ b/apps/profile/composer/composer.json
@@ -0,0 +1,13 @@
+{
+ "config" : {
+ "vendor-dir": ".",
+ "optimize-autoloader": true,
+ "classmap-authoritative": true,
+ "autoloader-suffix": "Profile"
+ },
+ "autoload" : {
+ "psr-4": {
+ "OCA\\Profile\\": "../lib/"
+ }
+ }
+}
diff --git a/apps/profile/composer/composer.lock b/apps/profile/composer/composer.lock
new file mode 100644
index 00000000000..fd0bcbcb753
--- /dev/null
+++ b/apps/profile/composer/composer.lock
@@ -0,0 +1,18 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "d751713988987e9331980363e24189ce",
+ "packages": [],
+ "packages-dev": [],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": [],
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": [],
+ "platform-dev": [],
+ "plugin-api-version": "2.1.0"
+}
diff --git a/apps/profile/composer/composer/ClassLoader.php b/apps/profile/composer/composer/ClassLoader.php
new file mode 100644
index 00000000000..7824d8f7eaf
--- /dev/null
+++ b/apps/profile/composer/composer/ClassLoader.php
@@ -0,0 +1,579 @@
+<?php
+
+/*
+ * This file is part of Composer.
+ *
+ * (c) Nils Adermann <naderman@naderman.de>
+ * Jordi Boggiano <j.boggiano@seld.be>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Composer\Autoload;
+
+/**
+ * ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
+ *
+ * $loader = new \Composer\Autoload\ClassLoader();
+ *
+ * // register classes with namespaces
+ * $loader->add('Symfony\Component', __DIR__.'/component');
+ * $loader->add('Symfony', __DIR__.'/framework');
+ *
+ * // activate the autoloader
+ * $loader->register();
+ *
+ * // to enable searching the include path (eg. for PEAR packages)
+ * $loader->setUseIncludePath(true);
+ *
+ * In this example, if you try to use a class in the Symfony\Component
+ * namespace or one of its children (Symfony\Component\Console for instance),
+ * the autoloader will first look for the class under the component/
+ * directory, and it will then fallback to the framework/ directory if not
+ * found before giving up.
+ *
+ * This class is loosely based on the Symfony UniversalClassLoader.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ * @author Jordi Boggiano <j.boggiano@seld.be>
+ * @see https://www.php-fig.org/psr/psr-0/
+ * @see https://www.php-fig.org/psr/psr-4/
+ */
+class ClassLoader
+{
+ /** @var \Closure(string):void */
+ private static $includeFile;
+
+ /** @var string|null */
+ private $vendorDir;
+
+ // PSR-4
+ /**
+ * @var array<string, array<string, int>>
+ */
+ private $prefixLengthsPsr4 = array();
+ /**
+ * @var array<string, list<string>>
+ */
+ private $prefixDirsPsr4 = array();
+ /**
+ * @var list<string>
+ */
+ private $fallbackDirsPsr4 = array();
+
+ // PSR-0
+ /**
+ * List of PSR-0 prefixes
+ *
+ * Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2')))
+ *
+ * @var array<string, array<string, list<string>>>
+ */
+ private $prefixesPsr0 = array();
+ /**
+ * @var list<string>
+ */
+ private $fallbackDirsPsr0 = array();
+
+ /** @var bool */
+ private $useIncludePath = false;
+
+ /**
+ * @var array<string, string>
+ */
+ private $classMap = array();
+
+ /** @var bool */
+ private $classMapAuthoritative = false;
+
+ /**
+ * @var array<string, bool>
+ */
+ private $missingClasses = array();
+
+ /** @var string|null */
+ private $apcuPrefix;
+
+ /**
+ * @var array<string, self>
+ */
+ private static $registeredLoaders = array();
+
+ /**
+ * @param string|null $vendorDir
+ */
+ public function __construct($vendorDir = null)
+ {
+ $this->vendorDir = $vendorDir;
+ self::initializeIncludeClosure();
+ }
+
+ /**
+ * @return array<string, list<string>>
+ */
+ public function getPrefixes()
+ {
+ if (!empty($this->prefixesPsr0)) {
+ return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
+ }
+
+ return array();
+ }
+
+ /**
+ * @return array<string, list<string>>
+ */
+ public function getPrefixesPsr4()
+ {
+ return $this->prefixDirsPsr4;
+ }
+
+ /**
+ * @return list<string>
+ */
+ public function getFallbackDirs()
+ {
+ return $this->fallbackDirsPsr0;
+ }
+
+ /**
+ * @return list<string>
+ */
+ public function getFallbackDirsPsr4()
+ {
+ return $this->fallbackDirsPsr4;
+ }
+
+ /**
+ * @return array<string, string> Array of classname => path
+ */
+ public function getClassMap()
+ {
+ return $this->classMap;
+ }
+
+ /**
+ * @param array<string, string> $classMap Class to filename map
+ *
+ * @return void
+ */
+ public function addClassMap(array $classMap)
+ {
+ if ($this->classMap) {
+ $this->classMap = array_merge($this->classMap, $classMap);
+ } else {
+ $this->classMap = $classMap;
+ }
+ }
+
+ /**
+ * Registers a set of PSR-0 directories for a given prefix, either
+ * appending or prepending to the ones previously set for this prefix.
+ *
+ * @param string $prefix The prefix
+ * @param list<string>|string $paths The PSR-0 root directories
+ * @param bool $prepend Whether to prepend the directories
+ *
+ * @return void
+ */
+ public function add($prefix, $paths, $prepend = false)
+ {
+ $paths = (array) $paths;
+ if (!$prefix) {
+ if ($prepend) {
+ $this->fallbackDirsPsr0 = array_merge(
+ $paths,
+ $this->fallbackDirsPsr0
+ );
+ } else {
+ $this->fallbackDirsPsr0 = array_merge(
+ $this->fallbackDirsPsr0,
+ $paths
+ );
+ }
+
+ return;
+ }
+
+ $first = $prefix[0];
+ if (!isset($this->prefixesPsr0[$first][$prefix])) {
+ $this->prefixesPsr0[$first][$prefix] = $paths;
+
+ return;
+ }
+ if ($prepend) {
+ $this->prefixesPsr0[$first][$prefix] = array_merge(
+ $paths,
+ $this->prefixesPsr0[$first][$prefix]
+ );
+ } else {
+ $this->prefixesPsr0[$first][$prefix] = array_merge(
+ $this->prefixesPsr0[$first][$prefix],
+ $paths
+ );
+ }
+ }
+
+ /**
+ * Registers a set of PSR-4 directories for a given namespace, either
+ * appending or prepending to the ones previously set for this namespace.
+ *
+ * @param string $prefix The prefix/namespace, with trailing '\\'
+ * @param list<string>|string $paths The PSR-4 base directories
+ * @param bool $prepend Whether to prepend the directories
+ *
+ * @throws \InvalidArgumentException
+ *
+ * @return void
+ */
+ public function addPsr4($prefix, $paths, $prepend = false)
+ {
+ $paths = (array) $paths;
+ if (!$prefix) {
+ // Register directories for the root namespace.
+ if ($prepend) {
+ $this->fallbackDirsPsr4 = array_merge(
+ $paths,
+ $this->fallbackDirsPsr4
+ );
+ } else {
+ $this->fallbackDirsPsr4 = array_merge(
+ $this->fallbackDirsPsr4,
+ $paths
+ );
+ }
+ } elseif (!isset($this->prefixDirsPsr4[$prefix])) {
+ // Register directories for a new namespace.
+ $length = strlen($prefix);
+ if ('\\' !== $prefix[$length - 1]) {
+ throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
+ }
+ $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
+ $this->prefixDirsPsr4[$prefix] = $paths;
+ } elseif ($prepend) {
+ // Prepend directories for an already registered namespace.
+ $this->prefixDirsPsr4[$prefix] = array_merge(
+ $paths,
+ $this->prefixDirsPsr4[$prefix]
+ );
+ } else {
+ // Append directories for an already registered namespace.
+ $this->prefixDirsPsr4[$prefix] = array_merge(
+ $this->prefixDirsPsr4[$prefix],
+ $paths
+ );
+ }
+ }
+
+ /**
+ * Registers a set of PSR-0 directories for a given prefix,
+ * replacing any others previously set for this prefix.
+ *
+ * @param string $prefix The prefix
+ * @param list<string>|string $paths The PSR-0 base directories
+ *
+ * @return void
+ */
+ public function set($prefix, $paths)
+ {
+ if (!$prefix) {
+ $this->fallbackDirsPsr0 = (array) $paths;
+ } else {
+ $this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
+ }
+ }
+
+ /**
+ * Registers a set of PSR-4 directories for a given namespace,
+ * replacing any others previously set for this namespace.
+ *
+ * @param string $prefix The prefix/namespace, with trailing '\\'
+ * @param list<string>|string $paths The PSR-4 base directories
+ *
+ * @throws \InvalidArgumentException
+ *
+ * @return void
+ */
+ public function setPsr4($prefix, $paths)
+ {
+ if (!$prefix) {
+ $this->fallbackDirsPsr4 = (array) $paths;
+ } else {
+ $length = strlen($prefix);
+ if ('\\' !== $prefix[$length - 1]) {
+ throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
+ }
+ $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
+ $this->prefixDirsPsr4[$prefix] = (array) $paths;
+ }
+ }
+
+ /**
+ * Turns on searching the include path for class files.
+ *
+ * @param bool $useIncludePath
+ *
+ * @return void
+ */
+ public function setUseIncludePath($useIncludePath)
+ {
+ $this->useIncludePath = $useIncludePath;
+ }
+
+ /**
+ * Can be used to check if the autoloader uses the include path to check
+ * for classes.
+ *
+ * @return bool
+ */
+ public function getUseIncludePath()
+ {
+ return $this->useIncludePath;
+ }
+
+ /**
+ * Turns off searching the prefix and fallback directories for classes
+ * that have not been registered with the class map.
+ *
+ * @param bool $classMapAuthoritative
+ *
+ * @return void
+ */
+ public function setClassMapAuthoritative($classMapAuthoritative)
+ {
+ $this->classMapAuthoritative = $classMapAuthoritative;
+ }
+
+ /**
+ * Should class lookup fail if not found in the current class map?
+ *
+ * @return bool
+ */
+ public function isClassMapAuthoritative()
+ {
+ return $this->classMapAuthoritative;
+ }
+
+ /**
+ * APCu prefix to use to cache found/not-found classes, if the extension is enabled.
+ *
+ * @param string|null $apcuPrefix
+ *
+ * @return void
+ */
+ public function setApcuPrefix($apcuPrefix)
+ {
+ $this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
+ }
+
+ /**
+ * The APCu prefix in use, or null if APCu caching is not enabled.
+ *
+ * @return string|null
+ */
+ public function getApcuPrefix()
+ {
+ return $this->apcuPrefix;
+ }
+
+ /**
+ * Registers this instance as an autoloader.
+ *
+ * @param bool $prepend Whether to prepend the autoloader or not
+ *
+ * @return void
+ */
+ public function register($prepend = false)
+ {
+ spl_autoload_register(array($this, 'loadClass'), true, $prepend);
+
+ if (null === $this->vendorDir) {
+ return;
+ }
+
+ if ($prepend) {
+ self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders;
+ } else {
+ unset(self::$registeredLoaders[$this->vendorDir]);
+ self::$registeredLoaders[$this->vendorDir] = $this;
+ }
+ }
+
+ /**
+ * Unregisters this instance as an autoloader.
+ *
+ * @return void
+ */
+ public function unregister()
+ {
+ spl_autoload_unregister(array($this, 'loadClass'));
+
+ if (null !== $this->vendorDir) {
+ unset(self::$registeredLoaders[$this->vendorDir]);
+ }
+ }
+
+ /**
+ * Loads the given class or interface.
+ *
+ * @param string $class The name of the class
+ * @return true|null True if loaded, null otherwise
+ */
+ public function loadClass($class)
+ {
+ if ($file = $this->findFile($class)) {
+ $includeFile = self::$includeFile;
+ $includeFile($file);
+
+ return true;
+ }
+
+ return null;
+ }
+
+ /**
+ * Finds the path to the file where the class is defined.
+ *
+ * @param string $class The name of the class
+ *
+ * @return string|false The path if found, false otherwise
+ */
+ public function findFile($class)
+ {
+ // class map lookup
+ if (isset($this->classMap[$class])) {
+ return $this->classMap[$class];
+ }
+ if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
+ return false;
+ }
+ if (null !== $this->apcuPrefix) {
+ $file = apcu_fetch($this->apcuPrefix.$class, $hit);
+ if ($hit) {
+ return $file;
+ }
+ }
+
+ $file = $this->findFileWithExtension($class, '.php');
+
+ // Search for Hack files if we are running on HHVM
+ if (false === $file && defined('HHVM_VERSION')) {
+ $file = $this->findFileWithExtension($class, '.hh');
+ }
+
+ if (null !== $this->apcuPrefix) {
+ apcu_add($this->apcuPrefix.$class, $file);
+ }
+
+ if (false === $file) {
+ // Remember that this class does not exist.
+ $this->missingClasses[$class] = true;
+ }
+
+ return $file;
+ }
+
+ /**
+ * Returns the currently registered loaders keyed by their corresponding vendor directories.
+ *
+ * @return array<string, self>
+ */
+ public static function getRegisteredLoaders()
+ {
+ return self::$registeredLoaders;
+ }
+
+ /**
+ * @param string $class
+ * @param string $ext
+ * @return string|false
+ */
+ private function findFileWithExtension($class, $ext)
+ {
+ // PSR-4 lookup
+ $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
+
+ $first = $class[0];
+ if (isset($this->prefixLengthsPsr4[$first])) {
+ $subPath = $class;
+ while (false !== $lastPos = strrpos($subPath, '\\')) {
+ $subPath = substr($subPath, 0, $lastPos);
+ $search = $subPath . '\\';
+ if (isset($this->prefixDirsPsr4[$search])) {
+ $pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
+ foreach ($this->prefixDirsPsr4[$search] as $dir) {
+ if (file_exists($file = $dir . $pathEnd)) {
+ return $file;
+ }
+ }
+ }
+ }
+ }
+
+ // PSR-4 fallback dirs
+ foreach ($this->fallbackDirsPsr4 as $dir) {
+ if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
+ return $file;
+ }
+ }
+
+ // PSR-0 lookup
+ if (false !== $pos = strrpos($class, '\\')) {
+ // namespaced class name
+ $logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
+ . strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
+ } else {
+ // PEAR-like class name
+ $logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
+ }
+
+ if (isset($this->prefixesPsr0[$first])) {
+ foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
+ if (0 === strpos($class, $prefix)) {
+ foreach ($dirs as $dir) {
+ if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
+ return $file;
+ }
+ }
+ }
+ }
+ }
+
+ // PSR-0 fallback dirs
+ foreach ($this->fallbackDirsPsr0 as $dir) {
+ if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
+ return $file;
+ }
+ }
+
+ // PSR-0 include paths.
+ if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
+ return $file;
+ }
+
+ return false;
+ }
+
+ /**
+ * @return void
+ */
+ private static function initializeIncludeClosure()
+ {
+ if (self::$includeFile !== null) {
+ return;
+ }
+
+ /**
+ * Scope isolated include.
+ *
+ * Prevents access to $this/self from included files.
+ *
+ * @param string $file
+ * @return void
+ */
+ self::$includeFile = \Closure::bind(static function($file) {
+ include $file;
+ }, null, null);
+ }
+}
diff --git a/apps/profile/composer/composer/InstalledVersions.php b/apps/profile/composer/composer/InstalledVersions.php
new file mode 100644
index 00000000000..51e734a774b
--- /dev/null
+++ b/apps/profile/composer/composer/InstalledVersions.php
@@ -0,0 +1,359 @@
+<?php
+
+/*
+ * This file is part of Composer.
+ *
+ * (c) Nils Adermann <naderman@naderman.de>
+ * Jordi Boggiano <j.boggiano@seld.be>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Composer;
+
+use Composer\Autoload\ClassLoader;
+use Composer\Semver\VersionParser;
+
+/**
+ * This class is copied in every Composer installed project and available to all
+ *
+ * See also https://getcomposer.org/doc/07-runtime.md#installed-versions
+ *
+ * To require its presence, you can require `composer-runtime-api ^2.0`
+ *
+ * @final
+ */
+class InstalledVersions
+{
+ /**
+ * @var mixed[]|null
+ * @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
+ */
+ private static $installed;
+
+ /**
+ * @var bool|null
+ */
+ private static $canGetVendors;
+
+ /**
+ * @var array[]
+ * @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
+ */
+ private static $installedByVendor = array();
+
+ /**
+ * Returns a list of all package names which are present, either by being installed, replaced or provided
+ *
+ * @return string[]
+ * @psalm-return list<string>
+ */
+ public static function getInstalledPackages()
+ {
+ $packages = array();
+ foreach (self::getInstalled() as $installed) {
+ $packages[] = array_keys($installed['versions']);
+ }
+
+ if (1 === \count($packages)) {
+ return $packages[0];
+ }
+
+ return array_keys(array_flip(\call_user_func_array('array_merge', $packages)));
+ }
+
+ /**
+ * Returns a list of all package names with a specific type e.g. 'library'
+ *
+ * @param string $type
+ * @return string[]
+ * @psalm-return list<string>
+ */
+ public static function getInstalledPackagesByType($type)
+ {
+ $packagesByType = array();
+
+ foreach (self::getInstalled() as $installed) {
+ foreach ($installed['versions'] as $name => $package) {
+ if (isset($package['type']) && $package['type'] === $type) {
+ $packagesByType[] = $name;
+ }
+ }
+ }
+
+ return $packagesByType;
+ }
+
+ /**
+ * Checks whether the given package is installed
+ *
+ * This also returns true if the package name is provided or replaced by another package
+ *
+ * @param string $packageName
+ * @param bool $includeDevRequirements
+ * @return bool
+ */
+ public static function isInstalled($packageName, $includeDevRequirements = true)
+ {
+ foreach (self::getInstalled() as $installed) {
+ if (isset($installed['versions'][$packageName])) {
+ return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Checks whether the given package satisfies a version constraint
+ *
+ * e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call:
+ *
+ * Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3')
+ *
+ * @param VersionParser $parser Install composer/semver to have access to this class and functionality
+ * @param string $packageName
+ * @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package
+ * @return bool
+ */
+ public static function satisfies(VersionParser $parser, $packageName, $constraint)
+ {
+ $constraint = $parser->parseConstraints((string) $constraint);
+ $provided = $parser->parseConstraints(self::getVersionRanges($packageName));
+
+ return $provided->matches($constraint);
+ }
+
+ /**
+ * Returns a version constraint representing all the range(s) which are installed for a given package
+ *
+ * It is easier to use this via isInstalled() with the $constraint argument if you need to check
+ * whether a given version of a package is installed, and not just whether it exists
+ *
+ * @param string $packageName
+ * @return string Version constraint usable with composer/semver
+ */
+ public static function getVersionRanges($packageName)
+ {
+ foreach (self::getInstalled() as $installed) {
+ if (!isset($installed['versions'][$packageName])) {
+ continue;
+ }
+
+ $ranges = array();
+ if (isset($installed['versions'][$packageName]['pretty_version'])) {
+ $ranges[] = $installed['versions'][$packageName]['pretty_version'];
+ }
+ if (array_key_exists('aliases', $installed['versions'][$packageName])) {
+ $ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']);
+ }
+ if (array_key_exists('replaced', $installed['versions'][$packageName])) {
+ $ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']);
+ }
+ if (array_key_exists('provided', $installed['versions'][$packageName])) {
+ $ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']);
+ }
+
+ return implode(' || ', $ranges);
+ }
+
+ throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
+ }
+
+ /**
+ * @param string $packageName
+ * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
+ */
+ public static function getVersion($packageName)
+ {
+ foreach (self::getInstalled() as $installed) {
+ if (!isset($installed['versions'][$packageName])) {
+ continue;
+ }
+
+ if (!isset($installed['versions'][$packageName]['version'])) {
+ return null;
+ }
+
+ return $installed['versions'][$packageName]['version'];
+ }
+
+ throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
+ }
+
+ /**
+ * @param string $packageName
+ * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
+ */
+ public static function getPrettyVersion($packageName)
+ {
+ foreach (self::getInstalled() as $installed) {
+ if (!isset($installed['versions'][$packageName])) {
+ continue;
+ }
+
+ if (!isset($installed['versions'][$packageName]['pretty_version'])) {
+ return null;
+ }
+
+ return $installed['versions'][$packageName]['pretty_version'];
+ }
+
+ throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
+ }
+
+ /**
+ * @param string $packageName
+ * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference
+ */
+ public static function getReference($packageName)
+ {
+ foreach (self::getInstalled() as $installed) {
+ if (!isset($installed['versions'][$packageName])) {
+ continue;
+ }
+
+ if (!isset($installed['versions'][$packageName]['reference'])) {
+ return null;
+ }
+
+ return $installed['versions'][$packageName]['reference'];
+ }
+
+ throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
+ }
+
+ /**
+ * @param string $packageName
+ * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path.
+ */
+ public static function getInstallPath($packageName)
+ {
+ foreach (self::getInstalled() as $installed) {
+ if (!isset($installed['versions'][$packageName])) {
+ continue;
+ }
+
+ return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null;
+ }
+
+ throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
+ }
+
+ /**
+ * @return array
+ * @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
+ */
+ public static function getRootPackage()
+ {
+ $installed = self::getInstalled();
+
+ return $installed[0]['root'];
+ }
+
+ /**
+ * Returns the raw installed.php data for custom implementations
+ *
+ * @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
+ * @return array[]
+ * @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
+ */
+ public static function getRawData()
+ {
+ @trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED);
+
+ if (null === self::$installed) {
+ // only require the installed.php file if this file is loaded from its dumped location,
+ // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
+ if (substr(__DIR__, -8, 1) !== 'C') {
+ self::$installed = include __DIR__ . '/installed.php';
+ } else {
+ self::$installed = array();
+ }
+ }
+
+ return self::$installed;
+ }
+
+ /**
+ * Returns the raw data of all installed.php which are currently loaded for custom implementations
+ *
+ * @return array[]
+ * @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
+ */
+ public static function getAllRawData()
+ {
+ return self::getInstalled();
+ }
+
+ /**
+ * Lets you reload the static array from another file
+ *
+ * This is only useful for complex integrations in which a project needs to use
+ * this class but then also needs to execute another project's autoloader in process,
+ * and wants to ensure both projects have access to their version of installed.php.
+ *
+ * A typical case would be PHPUnit, where it would need to make sure it reads all
+ * the data it needs from this class, then call reload() with
+ * `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure
+ * the project in which it runs can then also use this class safely, without
+ * interference between PHPUnit's dependencies and the project's dependencies.
+ *
+ * @param array[] $data A vendor/composer/installed.php data set
+ * @return void
+ *
+ * @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $data
+ */
+ public static function reload($data)
+ {
+ self::$installed = $data;
+ self::$installedByVendor = array();
+ }
+
+ /**
+ * @return array[]
+ * @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
+ */
+ private static function getInstalled()
+ {
+ if (null === self::$canGetVendors) {
+ self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders');
+ }
+
+ $installed = array();
+
+ if (self::$canGetVendors) {
+ foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
+ if (isset(self::$installedByVendor[$vendorDir])) {
+ $installed[] = self::$installedByVendor[$vendorDir];
+ } elseif (is_file($vendorDir.'/composer/installed.php')) {
+ /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
+ $required = require $vendorDir.'/composer/installed.php';
+ $installed[] = self::$installedByVendor[$vendorDir] = $required;
+ if (null === self::$installed && strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) {
+ self::$installed = $installed[count($installed) - 1];
+ }
+ }
+ }
+ }
+
+ if (null === self::$installed) {
+ // only require the installed.php file if this file is loaded from its dumped location,
+ // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
+ if (substr(__DIR__, -8, 1) !== 'C') {
+ /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
+ $required = require __DIR__ . '/installed.php';
+ self::$installed = $required;
+ } else {
+ self::$installed = array();
+ }
+ }
+
+ if (self::$installed !== array()) {
+ $installed[] = self::$installed;
+ }
+
+ return $installed;
+ }
+}
diff --git a/apps/profile/composer/composer/LICENSE b/apps/profile/composer/composer/LICENSE
new file mode 100644
index 00000000000..f27399a042d
--- /dev/null
+++ b/apps/profile/composer/composer/LICENSE
@@ -0,0 +1,21 @@
+
+Copyright (c) Nils Adermann, Jordi Boggiano
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished
+to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
diff --git a/apps/profile/composer/composer/autoload_classmap.php b/apps/profile/composer/composer/autoload_classmap.php
new file mode 100644
index 00000000000..1f4149e7210
--- /dev/null
+++ b/apps/profile/composer/composer/autoload_classmap.php
@@ -0,0 +1,11 @@
+<?php
+
+// autoload_classmap.php @generated by Composer
+
+$vendorDir = dirname(__DIR__);
+$baseDir = $vendorDir;
+
+return array(
+ 'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
+ 'OCA\\Profile\\Controller\\ProfilePageController' => $baseDir . '/../lib/Controller/ProfilePageController.php',
+);
diff --git a/apps/profile/composer/composer/autoload_namespaces.php b/apps/profile/composer/composer/autoload_namespaces.php
new file mode 100644
index 00000000000..3f5c9296251
--- /dev/null
+++ b/apps/profile/composer/composer/autoload_namespaces.php
@@ -0,0 +1,9 @@
+<?php
+
+// autoload_namespaces.php @generated by Composer
+
+$vendorDir = dirname(__DIR__);
+$baseDir = $vendorDir;
+
+return array(
+);
diff --git a/apps/profile/composer/composer/autoload_psr4.php b/apps/profile/composer/composer/autoload_psr4.php
new file mode 100644
index 00000000000..2417bba1809
--- /dev/null
+++ b/apps/profile/composer/composer/autoload_psr4.php
@@ -0,0 +1,10 @@
+<?php
+
+// autoload_psr4.php @generated by Composer
+
+$vendorDir = dirname(__DIR__);
+$baseDir = $vendorDir;
+
+return array(
+ 'OCA\\Profile\\' => array($baseDir . '/../lib'),
+);
diff --git a/apps/profile/composer/composer/autoload_real.php b/apps/profile/composer/composer/autoload_real.php
new file mode 100644
index 00000000000..5610a6a6702
--- /dev/null
+++ b/apps/profile/composer/composer/autoload_real.php
@@ -0,0 +1,37 @@
+<?php
+
+// autoload_real.php @generated by Composer
+
+class ComposerAutoloaderInitProfile
+{
+ private static $loader;
+
+ public static function loadClassLoader($class)
+ {
+ if ('Composer\Autoload\ClassLoader' === $class) {
+ require __DIR__ . '/ClassLoader.php';
+ }
+ }
+
+ /**
+ * @return \Composer\Autoload\ClassLoader
+ */
+ public static function getLoader()
+ {
+ if (null !== self::$loader) {
+ return self::$loader;
+ }
+
+ spl_autoload_register(array('ComposerAutoloaderInitProfile', 'loadClassLoader'), true, true);
+ self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
+ spl_autoload_unregister(array('ComposerAutoloaderInitProfile', 'loadClassLoader'));
+
+ require __DIR__ . '/autoload_static.php';
+ call_user_func(\Composer\Autoload\ComposerStaticInitProfile::getInitializer($loader));
+
+ $loader->setClassMapAuthoritative(true);
+ $loader->register(true);
+
+ return $loader;
+ }
+}
diff --git a/apps/profile/composer/composer/autoload_static.php b/apps/profile/composer/composer/autoload_static.php
new file mode 100644
index 00000000000..b7cc44a825e
--- /dev/null
+++ b/apps/profile/composer/composer/autoload_static.php
@@ -0,0 +1,37 @@
+<?php
+
+// autoload_static.php @generated by Composer
+
+namespace Composer\Autoload;
+
+class ComposerStaticInitProfile
+{
+ public static $prefixLengthsPsr4 = array (
+ 'O' =>
+ array (
+ 'OCA\\Profile\\' => 12,
+ ),
+ );
+
+ public static $prefixDirsPsr4 = array (
+ 'OCA\\Profile\\' =>
+ array (
+ 0 => __DIR__ . '/..' . '/../lib',
+ ),
+ );
+
+ public static $classMap = array (
+ 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
+ 'OCA\\Profile\\Controller\\ProfilePageController' => __DIR__ . '/..' . '/../lib/Controller/ProfilePageController.php',
+ );
+
+ public static function getInitializer(ClassLoader $loader)
+ {
+ return \Closure::bind(function () use ($loader) {
+ $loader->prefixLengthsPsr4 = ComposerStaticInitProfile::$prefixLengthsPsr4;
+ $loader->prefixDirsPsr4 = ComposerStaticInitProfile::$prefixDirsPsr4;
+ $loader->classMap = ComposerStaticInitProfile::$classMap;
+
+ }, null, ClassLoader::class);
+ }
+}
diff --git a/apps/profile/composer/composer/installed.json b/apps/profile/composer/composer/installed.json
new file mode 100644
index 00000000000..f20a6c47c6d
--- /dev/null
+++ b/apps/profile/composer/composer/installed.json
@@ -0,0 +1,5 @@
+{
+ "packages": [],
+ "dev": false,
+ "dev-package-names": []
+}
diff --git a/apps/profile/composer/composer/installed.php b/apps/profile/composer/composer/installed.php
new file mode 100644
index 00000000000..b1f92945b0a
--- /dev/null
+++ b/apps/profile/composer/composer/installed.php
@@ -0,0 +1,23 @@
+<?php return array(
+ 'root' => array(
+ 'name' => '__root__',
+ 'pretty_version' => 'dev-master',
+ 'version' => 'dev-master',
+ 'reference' => 'a489d88a2b2203cffdd08f4a5f1ae03a497bc52f',
+ 'type' => 'library',
+ 'install_path' => __DIR__ . '/../',
+ 'aliases' => array(),
+ 'dev' => false,
+ ),
+ 'versions' => array(
+ '__root__' => array(
+ 'pretty_version' => 'dev-master',
+ 'version' => 'dev-master',
+ 'reference' => 'a489d88a2b2203cffdd08f4a5f1ae03a497bc52f',
+ 'type' => 'library',
+ 'install_path' => __DIR__ . '/../',
+ 'aliases' => array(),
+ 'dev_requirement' => false,
+ ),
+ ),
+);
diff --git a/apps/profile/lib/Controller/ProfilePageController.php b/apps/profile/lib/Controller/ProfilePageController.php
new file mode 100644
index 00000000000..96a052b0bbf
--- /dev/null
+++ b/apps/profile/lib/Controller/ProfilePageController.php
@@ -0,0 +1,115 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Profile\Controller;
+
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http\Attribute\AnonRateLimit;
+use OCP\AppFramework\Http\Attribute\BruteForceProtection;
+use OCP\AppFramework\Http\Attribute\FrontpageRoute;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\OpenAPI;
+use OCP\AppFramework\Http\Attribute\PublicPage;
+use OCP\AppFramework\Http\Attribute\UserRateLimit;
+use OCP\AppFramework\Http\TemplateResponse;
+use OCP\AppFramework\Services\IInitialState;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\INavigationManager;
+use OCP\IRequest;
+use OCP\IUserManager;
+use OCP\IUserSession;
+use OCP\Profile\BeforeTemplateRenderedEvent;
+use OCP\Profile\IProfileManager;
+use OCP\Share\IManager as IShareManager;
+use OCP\UserStatus\IManager as IUserStatusManager;
+
+#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
+class ProfilePageController extends Controller {
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private IInitialState $initialStateService,
+ private IProfileManager $profileManager,
+ private IShareManager $shareManager,
+ private IUserManager $userManager,
+ private IUserSession $userSession,
+ private IUserStatusManager $userStatusManager,
+ private INavigationManager $navigationManager,
+ private IEventDispatcher $eventDispatcher,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ #[PublicPage]
+ #[NoCSRFRequired]
+ #[FrontpageRoute(verb: 'GET', url: '/u/{targetUserId}', root: '')]
+ #[BruteForceProtection(action: 'user')]
+ #[UserRateLimit(limit: 30, period: 120)]
+ #[AnonRateLimit(limit: 30, period: 120)]
+ public function index(string $targetUserId): TemplateResponse {
+ $profileNotFoundTemplate = new TemplateResponse(
+ 'profile',
+ '404-profile',
+ [],
+ TemplateResponse::RENDER_AS_GUEST,
+ );
+
+ $targetUser = $this->userManager->get($targetUserId);
+ if ($targetUser === null) {
+ $profileNotFoundTemplate->throttle();
+ return $profileNotFoundTemplate;
+ }
+ if (!$targetUser->isEnabled()) {
+ return $profileNotFoundTemplate;
+ }
+ $visitingUser = $this->userSession->getUser();
+
+ if (!$this->profileManager->isProfileEnabled($targetUser)) {
+ return $profileNotFoundTemplate;
+ }
+
+ // Run user enumeration checks only if viewing another user's profile
+ if ($targetUser !== $visitingUser) {
+ if (!$this->shareManager->currentUserCanEnumerateTargetUser($visitingUser, $targetUser)) {
+ return $profileNotFoundTemplate;
+ }
+ }
+
+ if ($visitingUser !== null) {
+ $userStatuses = $this->userStatusManager->getUserStatuses([$targetUserId]);
+ $status = $userStatuses[$targetUserId] ?? null;
+ if ($status !== null) {
+ $this->initialStateService->provideInitialState('status', [
+ 'icon' => $status->getIcon(),
+ 'message' => $status->getMessage(),
+ ]);
+ }
+ }
+
+ $this->initialStateService->provideInitialState(
+ 'profileParameters',
+ $this->profileManager->getProfileFields($targetUser, $visitingUser),
+ );
+
+ if ($targetUser === $visitingUser) {
+ $this->navigationManager->setActiveEntry('profile');
+ }
+
+ $this->eventDispatcher->dispatchTyped(new BeforeTemplateRenderedEvent($targetUserId));
+
+ \OCP\Util::addScript('profile', 'main');
+
+ return new TemplateResponse(
+ 'profile',
+ 'profile',
+ [],
+ $this->userSession->isLoggedIn() ? TemplateResponse::RENDER_AS_USER : TemplateResponse::RENDER_AS_PUBLIC,
+ );
+ }
+}
diff --git a/apps/profile/src/main.ts b/apps/profile/src/main.ts
new file mode 100644
index 00000000000..b48c6d5dc74
--- /dev/null
+++ b/apps/profile/src/main.ts
@@ -0,0 +1,27 @@
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getCSPNonce } from '@nextcloud/auth'
+import Vue from 'vue'
+
+import Profile from './views/Profile.vue'
+import ProfileSections from './services/ProfileSections.js'
+
+__webpack_nonce__ = getCSPNonce()
+
+if (!window.OCA) {
+ window.OCA = {}
+}
+
+if (!window.OCA.Core) {
+ window.OCA.Core = {}
+}
+Object.assign(window.OCA.Core, { ProfileSections: new ProfileSections() })
+
+const View = Vue.extend(Profile)
+
+window.addEventListener('DOMContentLoaded', () => {
+ new View().$mount('#content')
+})
diff --git a/apps/profile/src/services/ProfileSections.ts b/apps/profile/src/services/ProfileSections.ts
new file mode 100644
index 00000000000..9c6ca08e33f
--- /dev/null
+++ b/apps/profile/src/services/ProfileSections.ts
@@ -0,0 +1,25 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export default class ProfileSections {
+
+ _sections
+
+ constructor() {
+ this._sections = []
+ }
+
+ /**
+ * @param {registerSectionCallback} section To be called to mount the section to the profile page
+ */
+ registerSection(section) {
+ this._sections.push(section)
+ }
+
+ getSections() {
+ return this._sections
+ }
+
+}
diff --git a/apps/profile/src/views/Profile.vue b/apps/profile/src/views/Profile.vue
new file mode 100644
index 00000000000..6f120bd7a32
--- /dev/null
+++ b/apps/profile/src/views/Profile.vue
@@ -0,0 +1,492 @@
+<!--
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcContent app-name="profile">
+ <NcAppContent>
+ <div class="profile__header">
+ <div class="profile__header__container">
+ <div class="profile__header__container__placeholder" />
+ <div class="profile__header__container__displayname">
+ <h2>{{ displayname || userId }}</h2>
+ <span v-if="pronouns">·</span>
+ <span v-if="pronouns" class="profile__header__container__pronouns">{{ pronouns }}</span>
+ <NcButton v-if="isCurrentUser"
+ type="primary"
+ :href="settingsUrl">
+ <template #icon>
+ <PencilIcon :size="20" />
+ </template>
+ {{ t('profile', 'Edit Profile') }}
+ </NcButton>
+ </div>
+ <NcButton v-if="status.icon || status.message"
+ :disabled="!isCurrentUser"
+ :type="isCurrentUser ? 'tertiary' : 'tertiary-no-background'"
+ @click="openStatusModal">
+ {{ status.icon }} {{ status.message }}
+ </NcButton>
+ </div>
+ </div>
+
+ <div class="profile__wrapper">
+ <div class="profile__content">
+ <div class="profile__sidebar">
+ <NcAvatar class="avatar"
+ :class="{ interactive: isCurrentUser }"
+ :user="userId"
+ :size="180"
+ :show-user-status="true"
+ :show-user-status-compact="false"
+ :disable-menu="true"
+ :disable-tooltip="true"
+ :is-no-user="!isUserAvatarVisible"
+ @click.native.prevent.stop="openStatusModal" />
+
+ <div class="user-actions">
+ <!-- When a tel: URL is opened with target="_blank", a blank new tab is opened which is inconsistent with the handling of other URLs so we set target="_self" for the phone action -->
+ <NcButton v-if="primaryAction"
+ type="primary"
+ class="user-actions__primary"
+ :href="primaryAction.target"
+ :icon="primaryAction.icon"
+ :target="primaryAction.id === 'phone' ? '_self' :'_blank'">
+ <template #icon>
+ <!-- Fix for https://github.com/nextcloud-libraries/nextcloud-vue/issues/2315 -->
+ <img :src="primaryAction.icon" alt="" class="user-actions__primary__icon">
+ </template>
+ {{ primaryAction.title }}
+ </NcButton>
+ <NcActions class="user-actions__other" :inline="4">
+ <NcActionLink v-for="action in otherActions"
+ :key="action.id"
+ :close-after-click="true"
+ :href="action.target"
+ :target="action.id === 'phone' ? '_self' :'_blank'">
+ <template #icon>
+ <!-- Fix for https://github.com/nextcloud-libraries/nextcloud-vue/issues/2315 -->
+ <img :src="action.icon" alt="" class="user-actions__other__icon">
+ </template>
+ {{ action.title }}
+ </NcActionLink>
+ </NcActions>
+ </div>
+ </div>
+
+ <div class="profile__blocks">
+ <div v-if="organisation || role || address" class="profile__blocks-details">
+ <div v-if="organisation || role" class="detail">
+ <p>{{ organisation }} <span v-if="organisation && role">•</span> {{ role }}</p>
+ </div>
+ <div v-if="address" class="detail">
+ <p>
+ <MapMarkerIcon class="map-icon"
+ :size="16" />
+ {{ address }}
+ </p>
+ </div>
+ </div>
+ <template v-if="headline || biography || sections.length > 0">
+ <h3 v-if="headline" class="profile__blocks-headline">
+ {{ headline }}
+ </h3>
+ <p v-if="biography" class="profile__blocks-biography">
+ {{ biography }}
+ </p>
+
+ <!-- additional entries, use it with cautious -->
+ <div v-for="(section, index) in sections"
+ :ref="'section-' + index"
+ :key="index"
+ class="profile__additionalContent">
+ <component :is="section($refs['section-'+index], userId)" :user-id="userId" />
+ </div>
+ </template>
+ <NcEmptyContent v-else
+ class="profile__blocks-empty-info"
+ :name="emptyProfileMessage"
+ :description="t('profile', 'The headline and about sections will show up here')">
+ <template #icon>
+ <AccountIcon :size="60" />
+ </template>
+ </NcEmptyContent>
+ </div>
+ </div>
+ </div>
+ </NcAppContent>
+ </NcContent>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+import { generateUrl } from '@nextcloud/router'
+import { getCurrentUser } from '@nextcloud/auth'
+import { loadState } from '@nextcloud/initial-state'
+import { showError } from '@nextcloud/dialogs'
+import { subscribe, unsubscribe } from '@nextcloud/event-bus'
+import { translate as t } from '@nextcloud/l10n'
+
+import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
+import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink.js'
+import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
+import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcContent from '@nextcloud/vue/dist/Components/NcContent.js'
+import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
+import AccountIcon from 'vue-material-design-icons/Account.vue'
+import MapMarkerIcon from 'vue-material-design-icons/MapMarker.vue'
+import PencilIcon from 'vue-material-design-icons/Pencil.vue'
+
+interface IProfileAction {
+ target: string
+ icon: string
+ id: string
+ title: string
+}
+
+interface IStatus {
+ icon: string,
+ message: string,
+ userId: string,
+}
+
+export default defineComponent({
+ name: 'Profile',
+
+ components: {
+ AccountIcon,
+ MapMarkerIcon,
+ NcActionLink,
+ NcActions,
+ NcAppContent,
+ NcAvatar,
+ NcButton,
+ NcContent,
+ NcEmptyContent,
+ PencilIcon,
+ },
+
+ setup() {
+ return {
+ t,
+ }
+ },
+
+ data() {
+ const profileParameters = loadState('profile', 'profileParameters', {
+ userId: null as string|null,
+ displayname: null as string|null,
+ address: null as string|null,
+ organisation: null as string|null,
+ role: null as string|null,
+ headline: null as string|null,
+ biography: null as string|null,
+ actions: [] as IProfileAction[],
+ isUserAvatarVisible: false,
+ pronouns: null as string|null,
+ })
+
+ return {
+ ...profileParameters,
+ status: loadState<Partial<IStatus>>('profile', 'status', {}),
+ sections: window.OCA.Core.ProfileSections.getSections(),
+ }
+ },
+
+ computed: {
+ isCurrentUser() {
+ return getCurrentUser()?.uid === this.userId
+ },
+
+ allActions() {
+ return this.actions
+ },
+
+ primaryAction() {
+ if (this.allActions.length) {
+ return this.allActions[0]
+ }
+ return null
+ },
+
+ otherActions() {
+ if (this.allActions.length > 1) {
+ return this.allActions.slice(1)
+ }
+ return []
+ },
+
+ settingsUrl() {
+ return generateUrl('/settings/user')
+ },
+
+ emptyProfileMessage() {
+ return this.isCurrentUser
+ ? t('profile', 'You have not added any info yet')
+ : t('profile', '{user} has not added any info yet', { user: (this.displayname || this.userId || '') })
+ },
+ },
+
+ mounted() {
+ // Set the user's displayname or userId in the page title and preserve the default title of "Nextcloud" at the end
+ document.title = `${this.displayname || this.userId} - ${document.title}`
+ subscribe('user_status:status.updated', this.handleStatusUpdate)
+ },
+
+ beforeDestroy() {
+ unsubscribe('user_status:status.updated', this.handleStatusUpdate)
+ },
+
+ methods: {
+ handleStatusUpdate(status: IStatus) {
+ if (this.isCurrentUser && status.userId === this.userId) {
+ this.status = status
+ }
+ },
+
+ openStatusModal() {
+ const statusMenuItem = document.querySelector<HTMLButtonElement>('.user-status-menu-item')
+ // Changing the user status is only enabled if you are the current user
+ if (this.isCurrentUser) {
+ if (statusMenuItem) {
+ statusMenuItem.click()
+ } else {
+ showError(t('profile', 'Error opening the user status modal, try hard refreshing the page'))
+ }
+ }
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+$profile-max-width: 1024px;
+$content-max-width: 640px;
+
+:deep(#app-content-vue) {
+ background-color: unset;
+}
+
+.profile {
+ width: 100%;
+ overflow-y: auto;
+
+ &__header {
+ display: flex;
+ position: sticky;
+ height: 190px;
+ top: -40px;
+ background-color: var(--color-main-background-blur);
+ backdrop-filter: var(--filter-background-blur);
+ -webkit-backdrop-filter: var(--filter-background-blur);
+
+ &__container {
+ align-self: flex-end;
+ width: 100%;
+ max-width: $profile-max-width;
+ margin: 8px auto;
+ row-gap: 8px;
+ display: grid;
+ grid-template-rows: max-content max-content;
+ grid-template-columns: 240px 1fr;
+ justify-content: center;
+
+ &__placeholder {
+ grid-row: 1 / 3;
+ }
+
+ &__displayname {
+ padding-inline: 16px; // same as the status text button, see NcButton
+ width: $content-max-width;
+ height: 45px;
+ margin-block: 125px 0;
+ display: flex;
+ align-items: center;
+ gap: 18px;
+
+ h2 {
+ font-size: 30px;
+ margin: 0;
+ }
+
+ span {
+ font-size: 20px;
+ }
+ }
+ }
+ }
+
+ &__sidebar {
+ position: sticky;
+ top: 0;
+ align-self: flex-start;
+ padding-top: 20px;
+ min-width: 220px;
+ margin-block: -150px 0;
+ margin-inline: 0 20px;
+
+ // Specificity hack is needed to override Avatar component styles
+ :deep(.avatar.avatardiv) {
+ text-align: center;
+ margin: auto;
+ display: block;
+ padding: 8px;
+
+ &.interactive {
+ .avatardiv__user-status {
+ // Show that the status is interactive
+ cursor: pointer;
+ }
+ }
+
+ .avatardiv__user-status {
+ inset-inline-end: 14px;
+ bottom: 14px;
+ width: 34px;
+ height: 34px;
+ background-size: 28px;
+ border: none;
+ // Styles when custom status icon and status text are set
+ background-color: var(--color-main-background);
+ line-height: 34px;
+ font-size: 20px;
+ }
+ }
+ }
+
+ &__wrapper {
+ background-color: var(--color-main-background);
+ min-height: 100%;
+ }
+
+ &__content {
+ max-width: $profile-max-width;
+ margin: 0 auto;
+ display: flex;
+ width: 100%;
+ }
+
+ &__blocks {
+ margin: 18px 0 80px 0;
+ display: grid;
+ gap: 16px 0;
+ width: $content-max-width;
+
+ p, h3 {
+ cursor: text;
+ overflow-wrap: anywhere;
+ }
+
+ &-details {
+ display: flex;
+ flex-direction: column;
+ gap: 2px 0;
+
+ .detail {
+ display: inline-block;
+ color: var(--color-text-maxcontrast);
+
+ p .map-icon {
+ display: inline-block;
+ vertical-align: middle;
+ }
+ }
+ }
+
+ &-headline {
+ margin-inline: 0;
+ margin-block: 10px 0;
+ font-weight: bold;
+ font-size: 20px;
+ }
+
+ &-biography {
+ white-space: pre-line;
+ }
+ }
+}
+
+@media only screen and (max-width: 1024px) {
+ .profile {
+ &__header {
+ height: 250px;
+ position: unset;
+
+ &__container {
+ grid-template-columns: unset;
+ margin-bottom: 110px;
+
+ &__displayname {
+ margin: 80px 20px 0px 0px!important;
+ width: unset;
+ text-align: center;
+ padding-inline: 12px;
+ }
+
+ &__edit-button {
+ width: fit-content;
+ display: block;
+ margin: 60px auto;
+ }
+
+ &__status-text {
+ margin: 4px auto;
+ }
+ }
+ }
+
+ &__content {
+ display: block;
+
+ .avatar {
+ // Overlap avatar to top header
+ margin-top: -110px !important;
+ }
+ }
+
+ &__blocks {
+ width: unset;
+ max-width: 600px;
+ margin: 0 auto;
+ padding: 20px 50px 50px 50px;
+ }
+
+ &__sidebar {
+ margin: unset;
+ position: unset;
+ }
+ }
+}
+
+.user-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 8px 0;
+ margin-top: 20px;
+
+ &__primary {
+ margin: 0 auto;
+
+ &__icon {
+ filter: var(--primary-invert-if-dark);
+ }
+ }
+
+ &__other {
+ display: flex;
+ justify-content: center;
+ gap: 0 4px;
+
+ &__icon {
+ height: 20px;
+ width: 20px;
+ object-fit: contain;
+ filter: var(--background-invert-if-dark);
+ align-self: center;
+ margin: 12px; // so we get 44px x 44px
+ }
+ }
+}
+</style>
diff --git a/apps/profile/templates/404-profile.php b/apps/profile/templates/404-profile.php
new file mode 100644
index 00000000000..3f2d8731347
--- /dev/null
+++ b/apps/profile/templates/404-profile.php
@@ -0,0 +1,30 @@
+<?php
+/**
+ * SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+/** @var array $_ */
+/** @var \OCP\IL10N $l */
+/** @var \OCP\Defaults $theme */
+// @codeCoverageIgnoreStart
+if (!isset($_)) { //standalone page is not supported anymore - redirect to /
+ require_once '../../lib/base.php';
+
+ $urlGenerator = \OC::$server->getURLGenerator();
+ header('Location: ' . $urlGenerator->getAbsoluteURL('/'));
+ exit;
+}
+// @codeCoverageIgnoreEnd
+?>
+<?php if (isset($_['content'])) : ?>
+ <?php print_unescaped($_['content']) ?>
+<?php else : ?>
+ <div class="body-login-container update">
+ <div class="icon-big icon-error"></div>
+ <h2><?php p($l->t('Profile not found')); ?></h2>
+ <p class="infogroup"><?php p($l->t('The profile does not exist.')); ?></p>
+ <p><a class="button primary" href="<?php p(\OC::$server->getURLGenerator()->linkTo('', 'index.php')) ?>">
+ <?php p($l->t('Back to %s', [$theme->getName()])); ?>
+ </a></p>
+ </div>
+<?php endif; ?>
diff --git a/apps/profile/templates/profile.php b/apps/profile/templates/profile.php
new file mode 100644
index 00000000000..460bfcc4221
--- /dev/null
+++ b/apps/profile/templates/profile.php
@@ -0,0 +1,7 @@
+<?php
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+?>
+<div id="content"></div>
diff --git a/apps/profile/tests/Controller/ProfilePageControllerTest.php b/apps/profile/tests/Controller/ProfilePageControllerTest.php
new file mode 100644
index 00000000000..6c6c5ec79df
--- /dev/null
+++ b/apps/profile/tests/Controller/ProfilePageControllerTest.php
@@ -0,0 +1,78 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Profile\Tests\Controller;
+
+use OC\Profile\ProfileManager;
+use OC\UserStatus\Manager;
+use OCA\Profile\Controller\ProfilePageController;
+use OCP\AppFramework\Services\IInitialState;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\INavigationManager;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\IUserSession;
+use OCP\Share\IManager;
+use Test\TestCase;
+
+class ProfilePageControllerTest extends TestCase {
+
+ private IUserManager $userManager;
+ private ProfilePageController $controller;
+
+ protected function setUp(): void {
+ parent::setUp();
+
+ $request = $this->createMock(IRequest::class);
+ $initialStateService = $this->createMock(IInitialState::class);
+ $profileManager = $this->createMock(ProfileManager::class);
+ $shareManager = $this->createMock(IManager::class);
+ $this->userManager = $this->createMock(IUserManager::class);
+ $userSession = $this->createMock(IUserSession::class);
+ $userStatusManager = $this->createMock(Manager::class);
+ $navigationManager = $this->createMock(INavigationManager::class);
+ $eventDispatcher = $this->createMock(IEventDispatcher::class);
+
+ $this->controller = new ProfilePageController(
+ 'profile',
+ $request,
+ $initialStateService,
+ $profileManager,
+ $shareManager,
+ $this->userManager,
+ $userSession,
+ $userStatusManager,
+ $navigationManager,
+ $eventDispatcher,
+ );
+ }
+
+ public function testUserNotFound(): void {
+ $this->userManager->method('get')
+ ->willReturn(null);
+
+ $response = $this->controller->index('bob');
+
+ $this->assertTrue($response->isThrottled());
+ }
+
+ public function testUserDisabled(): void {
+ $user = $this->createMock(IUser::class);
+ $user->method('isEnabled')
+ ->willReturn(false);
+
+ $this->userManager->method('get')
+ ->willReturn($user);
+
+ $response = $this->controller->index('bob');
+
+ $this->assertFalse($response->isThrottled());
+ }
+}
diff --git a/apps/user_status/lib/Dashboard/UserStatusWidget.php b/apps/user_status/lib/Dashboard/UserStatusWidget.php
index c1091d4bb21..2870a2c1907 100644
--- a/apps/user_status/lib/Dashboard/UserStatusWidget.php
+++ b/apps/user_status/lib/Dashboard/UserStatusWidget.php
@@ -150,7 +150,7 @@ class UserStatusWidget implements IAPIWidget, IAPIWidgetV2, IIconWidget, IOption
$widgetData['icon'] . ($widgetData['icon'] ? ' ' : '') . $widgetData['message'] . ', ' . $formattedDate,
// https://nextcloud.local/index.php/u/julien
$this->urlGenerator->getAbsoluteURL(
- $this->urlGenerator->linkToRoute('core.ProfilePage.index', ['targetUserId' => $widgetData['userId']])
+ $this->urlGenerator->linkToRoute('profile.ProfilePage.index', ['targetUserId' => $widgetData['userId']])
),
$this->urlGenerator->getAbsoluteURL(
$this->urlGenerator->linkToRoute('core.avatar.getAvatar', ['userId' => $widgetData['userId'], 'size' => 44])