Subversion Repositories oidplus

Compare Revisions

No changes between revisions

Regard whitespace Rev 1264 → Rev 1265

/trunk/TODO
1,4 → 1,7
 
June 2023 planned:
- Finish REST API
 
May 2023 planned:
- Don't send "information object OIDs" (= Non-OIDs) OIDs to oid-info.com anymore
- In re Internet access:
/trunk/doc/config_values.md
635,6 → 635,20
Allow JWT tokens that were created using the RA-plugin
"Automated AJAX calls".
 
### JWT_ALLOW_REST_ADMIN
 
OIDplus::baseConfig()->setValue('JWT_ALLOW_REST_ADMIN', true);
 
Allow JWT tokens that were created using the admin-plugin
"REST API".
### JWT_ALLOW_REST_USER
 
OIDplus::baseConfig()->setValue('JWT_ALLOW_REST_USER', true);
 
Allow JWT tokens that were created using the RA-plugin
"REST API".
 
### JWT_ALLOW_LOGIN_ADMIN
 
OIDplus::baseConfig()->setValue('JWT_ALLOW_LOGIN_ADMIN', true);
/trunk/doc/developer_notes/feature_oids.md
108,3 → 108,7
 
- plugins/viathinksoft/adminPages/010_notifications/INTF_OID_1_3_6_1_4_1_37476_2_5_2_3_8.class.php containing the functions:
- getNotifications
 
- plugins/viathinksoft/publicPages/002_rest_api/INTF_OID_1_3_6_1_4_1_37476_2_5_2_3_9.class.php containing the functions:
- restApiCall
- restApiInfo
/trunk/includes/classes/OIDplus.class.php
2224,6 → 2224,12
* @throws OIDplusException
*/
public static function getCurrentLang() {
 
$rel_url = substr($_SERVER['REQUEST_URI'], strlen(OIDplus::webpath(null, OIDplus::PATH_RELATIVE_TO_ROOT)));
if (str_starts_with($rel_url, 'rest/')) { // <== TODO: Find a way how to move this into the plugin, since REST does not belong to the core. (Maybe some kind of "stateless mode" that is enabled by the REST plugin)
return self::getDefaultLang();
}
 
if (isset($_GET['lang'])) {
$lang = $_GET['lang'];
} else if (isset($_POST['lang'])) {
/trunk/includes/classes/OIDplusAuthContentStoreJWT.class.php
33,15 → 33,19
/**
* "Automated AJAX" plugin
*/
const JWT_GENERATOR_AJAX = 0;
const JWT_GENERATOR_AJAX = 10;
/**
* "REST API" plugin
*/
const JWT_GENERATOR_REST = 20;
/**
* "Remember me" login method
*/
const JWT_GENERATOR_LOGIN = 1;
const JWT_GENERATOR_LOGIN = 40;
/**
* "Manually crafted" JWT tokens
*/
const JWT_GENERATOR_MANUAL = 2;
const JWT_GENERATOR_MANUAL = 80;
 
/**
* @param int $gen OIDplusAuthContentStoreJWT::JWT_GENERATOR_...
49,11 → 53,23
* @return string
*/
private static function jwtGetBlacklistConfigKey(int $gen, string $sub): string {
// Note: Needs to be <= 50 characters!
// Note: Needs to be <= 50 characters! If $gen is 2 chars, then the config key is 49 chars long
return 'jwt_blacklist_gen('.$gen.')_sub('.trim(base64_encode(md5($sub,true)),'=').')';
}
 
/**
* @param int $gen
*/
private static function generatorName($gen) {
// Note: The strings are not translated, because the name is used in config keys or logs
if ($gen === self::JWT_GENERATOR_AJAX) return 'Automated AJAX calls';
if ($gen === self::JWT_GENERATOR_REST) return 'REST API';
if ($gen === self::JWT_GENERATOR_LOGIN) return 'Login ("Remember me")';
if ($gen === self::JWT_GENERATOR_MANUAL) return 'Manually created';
return 'Unknown generator';
}
 
/**
* @param int $gen OIDplusAuthContentStoreJWT::JWT_GENERATOR_...
* @param string $sub
* @return void
63,10 → 79,7
$cfg = self::jwtGetBlacklistConfigKey($gen, $sub);
$bl_time = time()-1;
 
$gen_desc = 'Unknown';
if ($gen === self::JWT_GENERATOR_AJAX) $gen_desc = 'Automated AJAX calls';
if ($gen === self::JWT_GENERATOR_LOGIN) $gen_desc = 'Login ("Remember me")';
if ($gen === self::JWT_GENERATOR_MANUAL) $gen_desc = 'Manually created';
$gen_desc = self::generatorName($gen);
 
OIDplus::config()->prepareConfigKey($cfg, 'Revoke timestamp of all JWT tokens for $sub with generator $gen ($gen_desc)', "$bl_time", OIDplusConfig::PROTECTION_HIDDEN, function($value) {});
OIDplus::config()->setValue($cfg, $bl_time);
84,11 → 97,13
}
 
/**
* Do various checks if the token is allowed and not blacklisted
* @param OIDplusAuthContentStore $contentProvider
* @param int $validGenerators Bitmask which generators to allow (-1 = allow all)
* @return void
* @throws OIDplusException
*/
private static function jwtSecurityCheck(OIDplusAuthContentStore $contentProvider) {
private static function jwtSecurityCheck(OIDplusAuthContentStore $contentProvider, int $validGenerators=-1) {
// Check if the token is intended for us
if ($contentProvider->getValue('aud','') !== OIDplus::getEditionInfo()['jwtaud']) {
throw new OIDplusException(_L('Token has wrong audience'));
109,6 → 124,16
throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_AJAX_USER'));
}
}
else if ($gen === self::JWT_GENERATOR_REST) {
if (($has_admin) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_REST_ADMIN', true)) {
// Generator: plugins/viathinksoft/adminPages/911_rest_api/OIDplusPageAdminRestApi.class.php
throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_REST_ADMIN'));
}
if (($has_ra) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_REST_USER', true)) {
// Generator: plugins/viathinksoft/raPages/911_rest_api/OIDplusPageRaRestApi.class.php
throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_REST_USER'));
}
}
else if ($gen === self::JWT_GENERATOR_LOGIN) {
// Used for feature "Remember me" (use JWT token in a cookie as alternative to PHP session):
// - No PHP session will be used
156,21 → 181,13
}
}
 
// Checks which are dependent on the generator
if ($gen === self::JWT_GENERATOR_LOGIN) {
if (!isset($_COOKIE[self::COOKIE_NAME])) {
throw new OIDplusException(_L('This kind of JWT token can only be used with the %1 request type','COOKIE'));
// Checks if JWT are dependent on the generator
if ($validGenerators !== -1) {
if (($gen & $validGenerators) === 0) {
throw new OIDplusException(_L('This kind of JWT token (%1) cannot be used in this request type', self::generatorName($gen)));
}
}
if ($gen === self::JWT_GENERATOR_AJAX) {
if (!isset($_GET[self::COOKIE_NAME]) && !isset($_POST[self::COOKIE_NAME])) {
throw new OIDplusException(_L('This kind of JWT token can only be used with the %1 request type','GET/POST'));
}
if (isset($_SERVER['SCRIPT_FILENAME']) && (strtolower(basename($_SERVER['SCRIPT_FILENAME'])) !== 'ajax.php')) {
throw new OIDplusException(_L('This kind of JWT token can only be used in ajax.php'));
}
}
}
 
// Override abstract functions
 
250,22 → 267,54
*/
public static function getActiveProvider()/*: ?OIDplusAuthContentStore*/ {
if (!self::$contentProvider) {
$jwt = '';
if (isset($_COOKIE[self::COOKIE_NAME])) $jwt = $_COOKIE[self::COOKIE_NAME];
if (isset($_POST[self::COOKIE_NAME])) $jwt = $_POST[self::COOKIE_NAME];
if (isset($_GET[self::COOKIE_NAME])) $jwt = $_GET[self::COOKIE_NAME];
 
if (!empty($jwt)) {
$tmp = new OIDplusAuthContentStoreJWT();
$tmp = null;
$silent_error = false;
 
try {
// Decode the JWT. In this step, the signature as well as EXP/NBF times will be checked
$tmp->loadJWT($jwt);
 
// Do various checks if the token is allowed and not blacklisted
self::jwtSecurityCheck($tmp);
$rel_url = substr($_SERVER['REQUEST_URI'], strlen(OIDplus::webpath(null, OIDplus::PATH_RELATIVE_TO_ROOT)));
if (str_starts_with($rel_url, 'rest/')) { // <== TODO: Find a way how to move this into the plugin, since REST does not belong to the core.
 
// REST may only use Bearer Authentication
$bearer = getBearerToken();
if (!is_null($bearer)) {
$silent_error = false;
$tmp = new OIDplusAuthContentStoreJWT();
$tmp->loadJWT($bearer);
self::jwtSecurityCheck($tmp, self::JWT_GENERATOR_REST | self::JWT_GENERATOR_MANUAL);
}
 
} else {
 
// A web-visitor (HTML and AJAX, but not REST) can use a JWT "remember me" Cookie
if (isset($_COOKIE[self::COOKIE_NAME])) {
$silent_error = true;
$tmp = new OIDplusAuthContentStoreJWT();
$tmp->loadJWT($_COOKIE[self::COOKIE_NAME]);
self::jwtSecurityCheck($tmp, self::JWT_GENERATOR_LOGIN | self::JWT_GENERATOR_MANUAL);
}
 
// AJAX may additionally use GET/POST automated AJAX (in addition to the normal JWT "remember me" Cookie)
if (isset($_SERVER['SCRIPT_FILENAME']) && (strtolower(basename($_SERVER['SCRIPT_FILENAME'])) !== 'ajax.php')) {
if (isset($_POST[self::COOKIE_NAME])) {
$silent_error = false;
$tmp = new OIDplusAuthContentStoreJWT();
$tmp->loadJWT($_POST[self::COOKIE_NAME]);
self::jwtSecurityCheck($tmp, self::JWT_GENERATOR_AJAX | self::JWT_GENERATOR_MANUAL);
}
if (isset($_GET[self::COOKIE_NAME])) {
$silent_error = false;
$tmp = new OIDplusAuthContentStoreJWT();
$tmp->loadJWT($_GET[self::COOKIE_NAME]);
self::jwtSecurityCheck($tmp, self::JWT_GENERATOR_AJAX | self::JWT_GENERATOR_MANUAL);
}
}
 
}
 
} catch (\Exception $e) {
if (isset($_GET[self::COOKIE_NAME]) || isset($_POST[self::COOKIE_NAME])) {
if (!$silent_error) {
// Most likely an AJAX request. We can throw an Exception
throw new OIDplusException(_L('The JWT token was rejected: %1',$e->getMessage()));
} else {
277,7 → 326,6
 
self::$contentProvider = $tmp;
}
}
 
return self::$contentProvider;
}
297,6 → 345,7
$gen = $this->getValue('oidplus_generator',-1);
switch ($gen) {
case OIDplusAuthContentStoreJWT::JWT_GENERATOR_AJAX :
case OIDplusAuthContentStoreJWT::JWT_GENERATOR_REST :
case OIDplusAuthContentStoreJWT::JWT_GENERATOR_MANUAL :
throw new OIDplusException(_L('This kind of JWT token cannot be altered. Therefore you cannot do this action.'));
case OIDplusAuthContentStoreJWT::JWT_GENERATOR_LOGIN :
327,6 → 376,7
$gen = $this->getValue('oidplus_generator',-1);
switch ($gen) {
case OIDplusAuthContentStoreJWT::JWT_GENERATOR_AJAX :
case OIDplusAuthContentStoreJWT::JWT_GENERATOR_REST :
case OIDplusAuthContentStoreJWT::JWT_GENERATOR_MANUAL :
throw new OIDplusException(_L('This kind of JWT token cannot be altered. Therefore you cannot do this action.'));
case OIDplusAuthContentStoreJWT::JWT_GENERATOR_LOGIN :
346,6 → 396,7
// Individual functions
 
/**
* Decode the JWT. In this step, the signature as well as EXP/NBF times will be checked
* @param string $jwt
* @return void
* @throws OIDplusException
/trunk/includes/classes/OIDplusAuthUtils.class.php
72,15 → 72,23
* @throws OIDplusException
*/
protected function getAuthContentStore()/*: ?OIDplusAuthContentStore*/ {
// TODO: Should we implement these AuthContentStore as plugin type, so that there can be more than just JWT and PHP session?
 
// Logged in via JWT
$tmp = OIDplusAuthContentStoreJWT::getActiveProvider();
if ($tmp) return $tmp;
 
// For REST, we must only allow JWT from Bearer and nothing else! So disable cookies if we are accessing the REST plugin
$rel_url = substr($_SERVER['REQUEST_URI'], strlen(OIDplus::webpath(null, OIDplus::PATH_RELATIVE_TO_ROOT)));
if (!str_starts_with($rel_url, 'rest/')) { // <== TODO: Find a way how to move this into the plugin, since REST does not belong to the core. (Maybe some kind of "stateless mode" that is enabled by the REST plugin)
 
// Normal login via web-browser
// Cookie will only be created once content is stored
$tmp = OIDplusAuthContentStoreSession::getActiveProvider();
if ($tmp) return $tmp;
 
}
 
// No active session and no JWT token available. User is not logged in.
return null;
}
/trunk/includes/functions.inc.php
618,3 → 618,41
$html = html_entity_decode($html, ENT_QUOTES, 'UTF-8');
return $html;
}
 
/**
* Get header Authorization
* @see https://stackoverflow.com/questions/40582161/how-to-properly-use-bearer-tokens
**/
function getAuthorizationHeader(){
$headers = null;
if (isset($_SERVER['Authorization'])) {
$headers = trim($_SERVER["Authorization"]);
}
else if (isset($_SERVER['HTTP_AUTHORIZATION'])) { //Nginx or fast CGI
$headers = trim($_SERVER["HTTP_AUTHORIZATION"]);
} elseif (function_exists('apache_request_headers')) {
$requestHeaders = apache_request_headers();
// Server-side fix for bug in old Android versions (a nice side-effect of this fix means we don't care about capitalization for Authorization)
$requestHeaders = array_combine(array_map('ucwords', array_keys($requestHeaders)), array_values($requestHeaders));
//print_r($requestHeaders);
if (isset($requestHeaders['Authorization'])) {
$headers = trim($requestHeaders['Authorization']);
}
}
return $headers;
}
 
/**
* get access token from header
* @see https://stackoverflow.com/questions/40582161/how-to-properly-use-bearer-tokens
**/
function getBearerToken() {
$headers = getAuthorizationHeader();
// HEADER: Get the access token from the header
if (!empty($headers)) {
if (preg_match('/Bearer\s(\S+)/', $headers, $matches)) {
return $matches[1];
}
}
return null;
}
/trunk/plugins/viathinksoft/adminPages/911_rest_api/OIDplusPageAdminRestApi.class.php
0,0 → 1,156
<?php
 
/*
* OIDplus 2.0
* Copyright 2019 - 2023 Daniel Marschall, ViaThinkSoft
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
 
// ATTENTION: If you change something, please make sure that the changes
// are synchronous with OIDplusPageRaRestApi
 
namespace ViaThinkSoft\OIDplus;
 
// phpcs:disable PSR1.Files.SideEffects
\defined('INSIDE_OIDPLUS') or die;
// phpcs:enable PSR1.Files.SideEffects
 
class OIDplusPageAdminRestApi extends OIDplusPagePluginAdmin {
 
/**
* @param string $actionID
* @param array $params
* @return array
* @throws OIDplusException
*/
public function action(string $actionID, array $params): array {
if ($actionID == 'blacklistJWT') {
if (!OIDplus::authUtils()->isAdminLoggedIn()) {
throw new OIDplusHtmlException(_L('You need to <a %1>log in</a> as administrator.',OIDplus::gui()->link('oidplus:login$admin')));
}
 
if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_REST_ADMIN', true)) {
throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_REST_ADMIN'));
}
 
$gen = OIDplusAuthContentStoreJWT::JWT_GENERATOR_REST;
$sub = 'admin';
 
OIDplusAuthContentStoreJWT::jwtBlacklist($gen, $sub);
 
return array("status" => 0);
} else {
return parent::action($actionID, $params);
}
}
 
/**
* @param string $id
* @param array $out
* @param bool $handled
* @return void
* @throws OIDplusException
*/
public function gui(string $id, array &$out, bool &$handled) {
if ($id === 'oidplus:rest_api_information_admin') {
$handled = true;
$out['title'] = _L('REST API');
$out['icon'] = file_exists(__DIR__.'/img/main_icon.png') ? OIDplus::webpath(__DIR__,OIDplus::PATH_RELATIVE).'img/main_icon.png' : '';
 
if (!OIDplus::authUtils()->isAdminLoggedIn()) {
throw new OIDplusHtmlException(_L('You need to <a %1>log in</a> as administrator.',OIDplus::gui()->link('oidplus:login$admin')), $out['title']);
}
 
if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_REST_ADMIN', true)) {
throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_REST_ADMIN'), $out['title']);
}
 
$gen = OIDplusAuthContentStoreJWT::JWT_GENERATOR_REST;
$sub = 'admin';
 
$authSimulation = new OIDplusAuthContentStoreJWT();
$authSimulation->adminLogin();
$authSimulation->setValue('oidplus_generator', $gen);
$token = $authSimulation->getJWTToken();
 
$out['text'] .= '<p>'._L('You can make automated calls to your OIDplus account by calling an REST API.').'</p>';
$out['text'] .= '<h2>'._L('Endpoints').'</h2>';
$endpoints = '';
foreach (OIDplus::getAllPlugins() as $plugin) {
if ($plugin instanceof INTF_OID_1_3_6_1_4_1_37476_2_5_2_3_9) {
$endpoints .= $plugin->restApiInfo('html');
}
}
if ($endpoints) {
$out['text'] .= '<p>'._L('The following endpoints are registered by the plugins in this system:').'</p>';
$out['text'] .= '<p>'.$endpoints.'</p>';
} else {
$out['text'] .= '<p>'._L('No installed plugin offers a REST functionality').'</p>';
}
$out['text'] .= '<h2>'._L('Authentication').'</h2>';
$out['text'] .= '<p>'._L('The authentication is done via the following HTTP header:').'</p>';
$out['text'] .= '<p><pre id="oidplus_auth_jwt">';
$out['text'] .= 'Authentication: Bearer '.htmlentities($token)."\n";
$out['text'] .= '</pre></p>';
$out['text'] .= '<p><input type="button" value="'._L('Copy to clipboard').'" onClick="copyToClipboard(oidplus_auth_jwt)"></p>';
$out['text'] .= '<p>'._L('Please keep this information confidential!').'</p>';
$out['text'] .= '<p>'._L('The JWT-token (secret!) will automatically perform a one-time-login to fulfill the request. The other fields are the normal fields which are called during the usual operation of OIDplus.').'</p>';
 
$out['text'] .= '<h2>'._L('Blacklisted tokens').'</h2>';
$bl_time = OIDplusAuthContentStoreJWT::jwtGetBlacklistTime($gen, $sub);
if ($bl_time == 0) {
$out['text'] .= '<p>'._L('None of the previously generated JWT tokens have been blacklisted.').'</p>';
} else {
$out['text'] .= '<p>'._L('All tokens generated before %1 have been blacklisted.',date('d F Y, H:i:s',$bl_time+1)).'</p>';
}
$out['text'] .= '<button type="button" name="btn_blacklist_jwt" id="btn_blacklist_jwt" class="btn btn-danger btn-xs" onclick="OIDplusPageAdminRestApi.blacklistJWT()">'._L('Blacklist all previously generated tokens').'</button>';
}
}
 
/**
* @param array $json
* @param string|null $ra_email
* @param bool $nonjs
* @param string $req_goto
* @return bool
* @throws OIDplusException
*/
public function tree(array &$json, string $ra_email=null, bool $nonjs=false, string $req_goto=''): bool {
if (!OIDplus::authUtils()->isAdminLoggedIn()) return false;
 
if (file_exists(__DIR__.'/img/main_icon16.png')) {
$tree_icon = OIDplus::webpath(__DIR__,OIDplus::PATH_RELATIVE).'img/main_icon16.png';
} else {
$tree_icon = null; // default icon (folder)
}
 
$json[] = array(
'id' => 'oidplus:rest_api_information_admin',
'icon' => $tree_icon,
'text' => _L('REST API')
);
 
// TODO: Make "Endpoints" (with all installed plugins) and "Authentication" as menu entries!
 
return true;
}
 
/**
* @param string $request
* @return array|false
*/
public function tree_search(string $request) {
return false;
}
}
/trunk/plugins/viathinksoft/adminPages/911_rest_api/OIDplusPageAdminRestApi.js
0,0 → 1,50
/*
* OIDplus 2.0
* Copyright 2019 - 2023 Daniel Marschall, ViaThinkSoft
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
 
var OIDplusPageAdminRestApi = {
 
oid: "1.3.6.1.4.1.37476.2.5.2.4.3.911",
 
blacklistJWT: function() {
if(!window.confirm(_L("Are you sure that you want to blacklist all access tokens?"))) return false;
 
$.ajax({
url:"ajax.php",
method:"POST",
beforeSend: function(jqXHR, settings) {
$.xhrPool.abortAll();
$.xhrPool.add(jqXHR);
},
complete: function(jqXHR, text) {
$.xhrPool.remove(jqXHR);
},
data: {
csrf_token:csrf_token,
plugin:OIDplusPageAdminRestApi.oid,
action:"blacklistJWT"
},
error: oidplus_ajax_error,
success: function (data) {
oidplus_ajax_success(data, function (data) {
alertSuccess(_L("OK"));
reloadContent();
});
}
});
}
 
};
/trunk/plugins/viathinksoft/adminPages/911_rest_api/img/index.html
Cannot display: file marked as a binary type.
svn:mime-type = application/octet-stream
/plugins/viathinksoft/adminPages/911_rest_api/img/main_icon.png
Property changes:
Added: svn:mime-type
+application/octet-stream
\ No newline at end of property
/trunk/plugins/viathinksoft/adminPages/911_rest_api/img/main_icon16.png
Cannot display: file marked as a binary type.
svn:mime-type = application/octet-stream
Property changes:
Added: svn:mime-type
+application/octet-stream
\ No newline at end of property
/trunk/plugins/viathinksoft/adminPages/911_rest_api/index.html
--- plugins/viathinksoft/adminPages/911_rest_api/manifest.xml (nonexistent)
+++ plugins/viathinksoft/adminPages/911_rest_api/manifest.xml (revision 1265)
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
+<manifest
+ xmlns="urn:oid:1.3.6.1.4.1.37476.2.5.2.5.2.1"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="urn:oid:1.3.6.1.4.1.37476.2.5.2.5.2.1 https://oidplus.viathinksoft.com/oidplus/plugins/manifest_plugin_page.xsd">
+
+ <type>ViaThinkSoft\OIDplus\OIDplusPagePluginAdmin</type>
+
+ <info>
+ <name>REST API</name>
+ <author>ViaThinkSoft</author>
+ <license>Apache 2.0</license>
+ <version />
+ <descriptionHTML />
+ <oid>1.3.6.1.4.1.37476.2.5.2.4.3.911</oid>
+ </info>
+
+ <php>
+ <mainclass>ViaThinkSoft\OIDplus\OIDplusPageAdminRestApi</mainclass>
+ </php>
+
+ <css>
+ </css>
+
+ <js>
+ <file>OIDplusPageAdminRestApi.js</file>
+ </js>
+
+</manifest>
/trunk/plugins/viathinksoft/language/dede/messages.xml
718,6 → 718,14
</message>
<message>
<source><![CDATA[
Authentication
]]></source>
<target><![CDATA[
Authentifizierung
]]></target>
</message>
<message>
<source><![CDATA[
Authentication error. Please log in as admin, or as the RA of "%1" to upload an attachment.
]]></source>
<target><![CDATA[
2118,6 → 2126,14
</message>
<message>
<source><![CDATA[
Endpoints
]]></source>
<target><![CDATA[
Endpunkte
]]></target>
</message>
<message>
<source><![CDATA[
Enforce SSL (always redirect)
]]></source>
<target><![CDATA[
3350,6 → 3366,14
</message>
<message>
<source><![CDATA[
Invalid REST API information format
]]></source>
<target><![CDATA[
Ungültiges REST API Dokumentations-Format
]]></target>
</message>
<message>
<source><![CDATA[
Invalid WEID
]]></source>
<target><![CDATA[
4238,6 → 4262,14
</message>
<message>
<source><![CDATA[
No installed plugin offers a REST functionality
]]></source>
<target><![CDATA[
Keine installierten Plugins bieten eine REST-Funktionalität an
]]></target>
</message>
<message>
<source><![CDATA[
No items available
]]></source>
<target><![CDATA[
5934,6 → 5966,14
</message>
<message>
<source><![CDATA[
REST API
]]></source>
<target><![CDATA[
REST API
]]></target>
</message>
<message>
<source><![CDATA[
RFC Internet Draft
]]></source>
<target><![CDATA[
7174,6 → 7214,14
</message>
<message>
<source><![CDATA[
The authentication is done via the following HTTP header:
]]></source>
<target><![CDATA[
Die Authentifizierung erfolgt über die folgende HTTP-Kopfzeile:
]]></target>
</message>
<message>
<source><![CDATA[
The database driver has problems with "%1"
]]></source>
<target><![CDATA[
7246,6 → 7294,14
</message>
<message>
<source><![CDATA[
The following endpoints are registered by the plugins in this system:
]]></source>
<target><![CDATA[
Die folgenden Endpunkte werden durch Plugins in diesem System zur Verfügung gestellt:
]]></target>
</message>
<message>
<source><![CDATA[
The following settings need to be configured once.<br>After setup is complete, you can change all these settings through the admin login area, if necessary.
]]></source>
<target><![CDATA[
7582,22 → 7638,14
</message>
<message>
<source><![CDATA[
This kind of JWT token can only be used in ajax.php
This kind of JWT token (%1) cannot be used in this request type
]]></source>
<target><![CDATA[
Dieser JWT Token kann nur in ajax.php verwendet werden
Diese Art von JWT-Token (%1) ist nicht für diesen Aufruf-Typ bestimmt
]]></target>
</message>
<message>
<source><![CDATA[
This kind of JWT token can only be used with the %1 request type
]]></source>
<target><![CDATA[
Dieser JWT Token kann nur mit dem %1 Anfragetyp verwendet werden
]]></target>
</message>
<message>
<source><![CDATA[
This kind of JWT token cannot be altered. Therefore you cannot do this action.
]]></source>
<target><![CDATA[
8390,6 → 8438,14
</message>
<message>
<source><![CDATA[
You can make automated calls to your OIDplus account by calling an REST API.
]]></source>
<target><![CDATA[
Die REST API bietet die Möglichkeit, Aufgaben automatisiert über Ihr OIDplus-Konto durchzuführen.
]]></target>
</message>
<message>
<source><![CDATA[
You can make automated calls to your OIDplus account by calling the AJAX API.
]]></source>
<target><![CDATA[
/trunk/plugins/viathinksoft/publicPages/000_objects/OIDplusPagePublicObjects.class.php
25,7 → 25,8
 
class OIDplusPagePublicObjects extends OIDplusPagePluginPublic
implements INTF_OID_1_3_6_1_4_1_37476_2_5_2_3_1, /* oobeEntry, oobeRequested */
INTF_OID_1_3_6_1_4_1_37476_2_5_2_3_8 /* getNotifications */
INTF_OID_1_3_6_1_4_1_37476_2_5_2_3_8, /* getNotifications */
INTF_OID_1_3_6_1_4_1_37476_2_5_2_3_9 /* restApiCall */
// Important: Do NOT implement INTF_OID_1_3_6_1_4_1_37476_2_5_2_3_7, because our getAlternativesForQuery() is the one that calls others!
{
 
58,6 → 59,71
}
 
/**
* Implements INTF_OID_1_3_6_1_4_1_37476_2_5_2_3_9
* @param string $requestMethod
* @param string $endpoint
* @return array|false
*/
public function restApiCall(string $requestMethod, string $endpoint) {
if (str_starts_with($endpoint, 'objects/')) {
$id = substr($endpoint, strlen('objects/'));
if ($requestMethod == "GET") {
// TODO: Implement GET (Select)
http_response_code(501);
return array("error" => "Not implemented");
} else if ($requestMethod == "PUT") {
// TODO: Implement PUT (Replace)
http_response_code(501);
return array("error" => "Not implemented");
} else if ($requestMethod == "POST") {
// TODO: Implement POST (Insert)
http_response_code(501);
return array("error" => "Not implemented");
} else if ($requestMethod == "PATCH") {
// TODO: Implement PATCH (Modify)
http_response_code(501);
return array("error" => "Not implemented");
} else if ($requestMethod == "DELETE") {
try {
self::action('Delete', array("id" => $id));
http_response_code(200);
return array("status" => "OK");
} catch (\Exception $e) {
http_response_code(401); // TODO: We need some kind of Exception class to know for sure that the Exception is due to missing authentication!
return array("error" => $e->getMessage());
}
} else {
http_response_code(400);
return array("error" => "Unsupported request method");
}
} else {
return false;
}
}
 
/**
* Implements INTF_OID_1_3_6_1_4_1_37476_2_5_2_3_9
* Outputs information about valid endpoints
* @param string $kind Reserved for different kind of output format (i.e. OpenAPI "TODO"). Currently only 'html' is implemented
* @return string
*/
public function restApiInfo(string $kind='html'): string {
if ($kind === 'html') {
// TODO: Make a good documentation.....
$out = '<ul><li><b>Objects API</b><ul>';
$out .= '<li>GET objects/[id]<ul><li>Input parameters: None</li><li>Output parameters: WORK IN PROGRESS</li></ul></li>';
$out .= '<li>PUT objects/[id]<ul><li>Input parameters: WORK IN PROGRESS</li><li>Output parameters: WORK IN PROGRESS</li></ul></li>';
$out .= '<li>POST objects/[id]<ul><li>Input parameters: WORK IN PROGRESS</li><li>Output parameters: WORK IN PROGRESS</li></ul></li>';
$out .= '<li>PATCH objects/[id]<ul><li>Input parameters: WORK IN PROGRESS</li><li>Output parameters: WORK IN PROGRESS</li></ul></li>';
$out .= '<li>DELETE objects/[id]<ul><li>Input parameters: None</li><li>Output parameters: WORK IN PROGRESS</li></ul></li>';
$out .= '</ul></li></ul>';
return $out;
} else {
throw new OIDplusException(_L('Invalid REST API information format'));
}
}
 
/**
* @param string $actionID
* @param array $params
* @return array
/trunk/plugins/viathinksoft/publicPages/002_rest_api/INTF_OID_1_3_6_1_4_1_37476_2_5_2_3_9.class.php
0,0 → 1,42
<?php
 
/*
* OIDplus 2.0
* Copyright 2019 - 2023 Daniel Marschall, ViaThinkSoft
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
 
namespace ViaThinkSoft\OIDplus;
 
// phpcs:disable PSR1.Files.SideEffects
\defined('INSIDE_OIDPLUS') or die;
// phpcs:enable PSR1.Files.SideEffects
 
interface INTF_OID_1_3_6_1_4_1_37476_2_5_2_3_9 {
 
/**
* @param string $requestMethod
* @param string $endpoint
* @return array|false
*/
public function restApiCall(string $requestMethod, string $endpoint);
 
/**
* Outputs information about valid endpoints
* @param string $kind Reserved for different kind of output format (i.e. OpenAPI "TODO"). Currently only 'html' is implemented
* @return string
*/
public function restApiInfo(string $kind='html'): string;
 
}
/trunk/plugins/viathinksoft/publicPages/002_rest_api/OIDplusPagePublicRestApi.class.php
0,0 → 1,71
<?php
 
/*
* OIDplus 2.0
* Copyright 2019 - 2023 Daniel Marschall, ViaThinkSoft
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
 
namespace ViaThinkSoft\OIDplus;
 
// TODO: should this be a different plugin type? A page without gui is weird!
// phpcs:disable PSR1.Files.SideEffects
\defined('INSIDE_OIDPLUS') or die;
// phpcs:enable PSR1.Files.SideEffects
 
class OIDplusPagePublicRestApi extends OIDplusPagePluginPublic {
 
/**
* @param string $request
* @return bool
* @throws OIDplusException
*/
public function handle404(string $request): bool {
 
if (!isset($_SERVER['REQUEST_URI']) || !isset($_SERVER["REQUEST_METHOD"])) return false;
 
$rel_url = substr($_SERVER['REQUEST_URI'], strlen(OIDplus::webpath(null, OIDplus::PATH_RELATIVE_TO_ROOT)));
$expect = 'rest/v1/';
if (str_starts_with($rel_url, $expect)) {
$rel_url = ltrim($rel_url, $expect);
 
$requestMethod = $_SERVER["REQUEST_METHOD"];
 
try {
$json_out = false;
foreach (OIDplus::getAllPlugins() as $plugin) {
if ($plugin instanceof INTF_OID_1_3_6_1_4_1_37476_2_5_2_3_9) {
$json_out = $plugin->restApiCall($requestMethod, $rel_url);
if ($json_out !== false) break;
}
}
if ($json_out === false) {
http_response_code(404);
$json_out = array("error" => "Endpoint not found");
}
} catch (\Exception $e) {
http_response_code(500);
$json_out = array("error" => $e->getMessage());
}
 
OIDplus::invoke_shutdown();
@header('Content-Type:application/json; charset=utf-8');
echo json_encode($json_out);
die(); // return true;
}
 
return false;
}
 
}
/trunk/plugins/viathinksoft/publicPages/002_rest_api/index.html
--- plugins/viathinksoft/publicPages/002_rest_api/manifest.xml (nonexistent)
+++ plugins/viathinksoft/publicPages/002_rest_api/manifest.xml (revision 1265)
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
+<manifest
+ xmlns="urn:oid:1.3.6.1.4.1.37476.2.5.2.5.2.1"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="urn:oid:1.3.6.1.4.1.37476.2.5.2.5.2.1 https://oidplus.viathinksoft.com/oidplus/plugins/manifest_plugin_page.xsd">
+
+ <type>ViaThinkSoft\OIDplus\OIDplusPagePluginPublic</type>
+
+ <info>
+ <name>REST API</name>
+ <author>ViaThinkSoft</author>
+ <license>Apache 2.0</license>
+ <version />
+ <descriptionHTML />
+ <oid>1.3.6.1.4.1.37476.2.5.2.4.1.2</oid>
+ </info>
+
+ <php>
+ <mainclass>ViaThinkSoft\OIDplus\OIDplusPagePublicRestApi</mainclass>
+ </php>
+
+ <css>
+ </css>
+
+ <js>
+ </js>
+
+</manifest>
/trunk/plugins/viathinksoft/raPages/910_automated_ajax_calls/OIDplusPageRaAutomatedAJAXCalls.class.php
94,7 → 94,7
$out['text'] .= '<p>'._L('The URL for the AJAX script is:').'</p>';
$out['text'] .= '<p><b>'.OIDplus::webpath(null,OIDplus::PATH_ABSOLUTE_CANONICAL).'ajax.php</b></p>';
$out['text'] .= '<p>'._L('You must at least provide following fields:').'</p>';
$out['text'] .= '<p><pre>';
$out['text'] .= '<p><pre id="oidplus_auth_jwt">';
$out['text'] .= htmlentities(OIDplusAuthContentStoreJWT::COOKIE_NAME).' = "'.htmlentities($token).'"'."\n";
$out['text'] .= '</pre></p>';
$out['text'] .= '<p><input type="button" value="'._L('Copy to clipboard').'" onClick="copyToClipboard(oidplus_auth_jwt)"></p>';
/trunk/plugins/viathinksoft/raPages/911_rest_api/OIDplusPageRaRestApi.class.php
0,0 → 1,163
<?php
 
/*
* OIDplus 2.0
* Copyright 2019 - 2023 Daniel Marschall, ViaThinkSoft
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
 
// ATTENTION: If you change something, please make sure that the changes
// are synchronous with OIDplusPageAdminRestApi
 
namespace ViaThinkSoft\OIDplus;
 
// phpcs:disable PSR1.Files.SideEffects
\defined('INSIDE_OIDPLUS') or die;
// phpcs:enable PSR1.Files.SideEffects
 
class OIDplusPageRaRestApi extends OIDplusPagePluginRa {
 
/**
* @param string $actionID
* @param array $params
* @return array
* @throws OIDplusException
*/
public function action(string $actionID, array $params): array {
if ($actionID == 'blacklistJWT') {
if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_REST_USER', true)) {
throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_REST_USER'));
}
 
_CheckParamExists($params, 'user');
$ra_email = $params['user'];
 
if (!OIDplus::authUtils()->isRaLoggedIn($ra_email) && !OIDplus::authUtils()->isAdminLoggedIn()) {
throw new OIDplusHtmlException(_L('You need to <a %1>log in</a> as the requested RA %2 or as admin.',OIDplus::gui()->link('oidplus:login$ra$'.$ra_email),'<b>'.htmlentities($ra_email).'</b>'));
}
 
$gen = OIDplusAuthContentStoreJWT::JWT_GENERATOR_REST;
$sub = $ra_email;
 
OIDplusAuthContentStoreJWT::jwtBlacklist($gen, $sub);
 
return array("status" => 0);
} else {
return parent::action($actionID, $params);
}
}
 
/**
* @param string $id
* @param array $out
* @param bool $handled
* @return void
* @throws OIDplusException
*/
public function gui(string $id, array &$out, bool &$handled) {
if (explode('$',$id)[0] == 'oidplus:rest_api_information_ra') {
$handled = true;
 
$ra_email = explode('$',$id)[1];
 
$out['title'] = _L('REST API');
$out['icon'] = file_exists(__DIR__.'/img/main_icon.png') ? OIDplus::webpath(__DIR__,OIDplus::PATH_RELATIVE).'img/main_icon.png' : '';
 
if (!OIDplus::authUtils()->isRaLoggedIn($ra_email) && !OIDplus::authUtils()->isAdminLoggedIn()) {
throw new OIDplusHtmlException(_L('You need to <a %1>log in</a> as the requested RA %2 or as admin.',OIDplus::gui()->link('oidplus:login$ra$'.$ra_email),'<b>'.htmlentities($ra_email).'</b>'), $out['title']);
}
 
if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_REST_USER', true)) {
throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_REST_USER'), $out['title']);
}
 
$gen = OIDplusAuthContentStoreJWT::JWT_GENERATOR_REST;
$sub = $ra_email;
 
$authSimulation = new OIDplusAuthContentStoreJWT();
$authSimulation->raLogin($ra_email);
$authSimulation->setValue('oidplus_generator', $gen);
$token = $authSimulation->getJWTToken();
 
$out['text'] .= '<p>'._L('You can make automated calls to your OIDplus account by calling an REST API.').'</p>';
$out['text'] .= '<h2>'._L('Endpoints').'</h2>';
$endpoints = '';
foreach (OIDplus::getAllPlugins() as $plugin) {
if ($plugin instanceof INTF_OID_1_3_6_1_4_1_37476_2_5_2_3_9) {
$endpoints .= $plugin->restApiInfo('html');
}
}
if ($endpoints) {
$out['text'] .= '<p>'._L('The following endpoints are registered by the plugins in this system:').'</p>';
$out['text'] .= '<p>'.$endpoints.'</p>';
} else {
$out['text'] .= '<p>'._L('No installed plugin offers a REST functionality').'</p>';
}
$out['text'] .= '<h2>'._L('Authentication').'</h2>';
$out['text'] .= '<p>'._L('The authentication is done via the following HTTP header:').'</p>';
$out['text'] .= '<p><pre id="oidplus_auth_jwt">';
$out['text'] .= 'Authentication: Bearer '.htmlentities($token)."\n";
$out['text'] .= '</pre></p>';
$out['text'] .= '<p><input type="button" value="'._L('Copy to clipboard').'" onClick="copyToClipboard(oidplus_auth_jwt)"></p>';
$out['text'] .= '<p>'._L('Please keep this information confidential!').'</p>';
$out['text'] .= '<p>'._L('The JWT-token (secret!) will automatically perform a one-time-login to fulfill the request. The other fields are the normal fields which are called during the usual operation of OIDplus.').'</p>';
 
$out['text'] .= '<h2>'._L('Blacklisted tokens').'</h2>';
$bl_time = OIDplusAuthContentStoreJWT::jwtGetBlacklistTime($gen, $sub);
if ($bl_time == 0) {
$out['text'] .= '<p>'._L('None of the previously generated JWT tokens have been blacklisted.').'</p>';
} else {
$out['text'] .= '<p>'._L('All tokens generated before %1 have been blacklisted.',date('d F Y, H:i:s',$bl_time+1)).'</p>';
}
$out['text'] .= '<button type="button" name="btn_blacklist_jwt" id="btn_blacklist_jwt" class="btn btn-danger btn-xs" onclick="OIDplusPageRaRestApi.blacklistJWT('.js_escape($ra_email).')">'._L('Blacklist all previously generated tokens').'</button>';
}
}
 
/**
* @param array $json
* @param string|null $ra_email
* @param bool $nonjs
* @param string $req_goto
* @return bool
* @throws OIDplusException
*/
public function tree(array &$json, string $ra_email=null, bool $nonjs=false, string $req_goto=''): bool {
if (!$ra_email) return false;
if (!OIDplus::authUtils()->isRaLoggedIn($ra_email) && !OIDplus::authUtils()->isAdminLoggedIn()) return false;
 
if (file_exists(__DIR__.'/img/main_icon16.png')) {
$tree_icon = OIDplus::webpath(__DIR__,OIDplus::PATH_RELATIVE).'img/main_icon16.png';
} else {
$tree_icon = null; // default icon (folder)
}
 
$json[] = array(
'id' => 'oidplus:rest_api_information_ra$'.$ra_email,
'icon' => $tree_icon,
'text' => _L('REST API')
);
 
// TODO: Make "Endpoints" (with all installed plugins) and "Authentication" as menu entries!
 
return true;
}
 
/**
* @param string $request
* @return array|false
*/
public function tree_search(string $request) {
return false;
}
}
/trunk/plugins/viathinksoft/raPages/911_rest_api/OIDplusPageRaRestApi.js
0,0 → 1,51
/*
* OIDplus 2.0
* Copyright 2019 - 2023 Daniel Marschall, ViaThinkSoft
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
 
var OIDplusPageRaRestApi = {
 
oid: "1.3.6.1.4.1.37476.2.5.2.4.2.911",
 
blacklistJWT: function(user) {
if(!window.confirm(_L("Are you sure that you want to blacklist all access tokens?"))) return false;
 
$.ajax({
url:"ajax.php",
method:"POST",
beforeSend: function(jqXHR, settings) {
$.xhrPool.abortAll();
$.xhrPool.add(jqXHR);
},
complete: function(jqXHR, text) {
$.xhrPool.remove(jqXHR);
},
data: {
csrf_token:csrf_token,
plugin:OIDplusPageRaRestApi.oid,
action:"blacklistJWT",
user:user
},
error: oidplus_ajax_error,
success: function (data) {
oidplus_ajax_success(data, function (data) {
alertSuccess(_L("OK"));
reloadContent();
});
}
});
}
 
};
/trunk/plugins/viathinksoft/raPages/911_rest_api/img/index.html
Cannot display: file marked as a binary type.
svn:mime-type = application/octet-stream
/plugins/viathinksoft/raPages/911_rest_api/img/main_icon.png
Property changes:
Added: svn:mime-type
+application/octet-stream
\ No newline at end of property
/trunk/plugins/viathinksoft/raPages/911_rest_api/img/main_icon16.png
Cannot display: file marked as a binary type.
svn:mime-type = application/octet-stream
Property changes:
Added: svn:mime-type
+application/octet-stream
\ No newline at end of property
/trunk/plugins/viathinksoft/raPages/911_rest_api/index.html
--- plugins/viathinksoft/raPages/911_rest_api/manifest.xml (nonexistent)
+++ plugins/viathinksoft/raPages/911_rest_api/manifest.xml (revision 1265)
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
+<manifest
+ xmlns="urn:oid:1.3.6.1.4.1.37476.2.5.2.5.2.1"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="urn:oid:1.3.6.1.4.1.37476.2.5.2.5.2.1 https://oidplus.viathinksoft.com/oidplus/plugins/manifest_plugin_page.xsd">
+
+ <type>ViaThinkSoft\OIDplus\OIDplusPagePluginRa</type>
+
+ <info>
+ <name>REST API</name>
+ <author>ViaThinkSoft</author>
+ <license>Apache 2.0</license>
+ <version />
+ <descriptionHTML />
+ <oid>1.3.6.1.4.1.37476.2.5.2.4.2.911</oid>
+ </info>
+
+ <php>
+ <mainclass>ViaThinkSoft\OIDplus\OIDplusPageRaRestApi</mainclass>
+ </php>
+
+ <css>
+ </css>
+
+ <js>
+ <file>OIDplusPageRaRestApi.js</file>
+ </js>
+
+</manifest>