diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e8d8e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +Thumbs.db +*.bak +*.bak-* +*.zip +*.log diff --git a/OjsWpSsoSubscriptionPlugin.php b/OjsWpSsoSubscriptionPlugin.php new file mode 100644 index 0000000..0a1d459 --- /dev/null +++ b/OjsWpSsoSubscriptionPlugin.php @@ -0,0 +1,561 @@ +getEnabled()) { + // Sync + periodic revalidation of subscription status (reads JWT + // id_token from the OpenID plugin session, falls back to userinfo). + // NOTE: the third-party OpenID plugin does NOT emit an + // "authenticated" hook, so we rely on LoadHandler and sync lazily. + Hook::add('LoadHandler', [$this, 'validateSubscriptionOnRequest']); + + // Hide subscription issues from archive + Hook::add('IssueGridHandler::fetchGrid', [$this, 'filterIssueArchive']); + + // After core subscription check: sync OIDC claim into DB and re-evaluate + // (IssueHandler / ArticleHandler gate PDFs on subscribedUser). + Hook::add('IssueAction::subscribedUser', [$this, 'hookIssueActionSubscribedUser'], Hook::SEQUENCE_LAST); + + // Homepage only: OJS 3.4 has no IssueHandler::view hook; do not swap + // frontend/pages/issue.tpl — logged-in users without a synced OJS row would + // see only "subscription required" while the core would show TOC + locked PDFs. + Hook::add('TemplateManager::display', [$this, 'filterTemplateManagerDisplay']); + + // AboutHandler::subscriptions redirects to index when payments are not + // enabled+configured. SSO-only sites hit that, so "PDF denied → about/subscriptions + // → index" looks like a double bounce. Aborting that redirect lets the + // subscriptions page render (contact info, types, etc.). + Hook::add('Request::redirect', [$this, 'hookRequestRedirect'], Hook::SEQUENCE_NORMAL); + } + + return $success; + } + + public function getDisplayName() + { + return __('plugins.generic.ojsWpSsoSubscription.displayName'); + } + + public function getDescription() + { + return __('plugins.generic.ojsWpSsoSubscription.description'); + } + + // ------------------------------------------------------------------------- + // Settings + // ------------------------------------------------------------------------- + + public function getActions($request, $actionArgs) + { + $actions = parent::getActions($request, $actionArgs); + if (!$this->getEnabled()) { + return $actions; + } + + $router = $request->getRouter(); + + array_unshift( + $actions, + new \PKP\linkAction\LinkAction( + 'settings', + new \PKP\linkAction\request\AjaxModal( + $router->url($request, null, null, 'manage', null, [ + 'verb' => 'settings', + 'plugin' => $this->getName(), + 'category' => 'generic', + ]), + $this->getDisplayName() + ), + __('manager.plugins.settings'), + null + ) + ); + + return $actions; + } + + public function manage($args, $request) + { + switch ($request->getUserVar('verb')) { + case 'settings': + $context = $request->getContext(); + $contextId = $context ? $context->getId() : \PKP\core\PKPApplication::CONTEXT_SITE; + + $formFile = __DIR__ . '/OjsWpSsoSubscriptionSettingsForm.php'; + if (!file_exists($formFile)) { + $formFile = __DIR__ . '/OjsWpSsoSubscriptionSettingsForm.inc.php'; + } + require_once $formFile; + + $form = new OjsWpSsoSubscriptionSettingsForm($this, $contextId); + if ($request->getUserVar('save')) { + $form->readInputData(); + if ($form->validate()) { + $form->execute(); + return new \PKP\core\JSONMessage(true); + } + } else { + $form->initData(); + } + return new \PKP\core\JSONMessage(true, $form->fetch($request)); + } + return parent::manage($args, $request); + } + + // ------------------------------------------------------------------------- + // Subscription sync & periodic revalidation + // ------------------------------------------------------------------------- + + /** + * Decode an OIDC JWT id_token (signature NOT re-verified: the OpenID plugin + * already did that before storing it). + * + * @return array|null Associative array of claims, or null on failure. + */ + private function decodeIdTokenClaims($idToken) + { + if (empty($idToken) || !is_string($idToken) || substr_count($idToken, '.') < 2) { + return null; + } + $parts = explode('.', $idToken); + $payload = strtr($parts[1], '-_', '+/'); + $pad = strlen($payload) % 4; + if ($pad) { + $payload .= str_repeat('=', 4 - $pad); + } + $json = base64_decode($payload, true); + if ($json === false) { + return null; + } + $claims = json_decode($json, true); + return is_array($claims) ? $claims : null; + } + + /** + * Permissive truth check for the subscription claim: + * true, 1, "1", "true", "yes", "on" are all truthy. + */ + private function claimIsTruthy($value) + { + if ($value === true || $value === 1) { + return true; + } + if (is_string($value)) { + return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true); + } + return false; + } + + /** + * Raw id_token string as stored in session (may be encrypted by OpenID plugin). + */ + private function getSessionIdTokenRaw() + { + try { + $session = Application::get()->getRequest()->getSession(); + if (!$session) { + return null; + } + $keyName = 'id_token'; + if (class_exists('\\APP\\plugins\\generic\\openid\\OpenIDPlugin') + && defined('\\APP\\plugins\\generic\\openid\\OpenIDPlugin::ID_TOKEN_NAME')) { + $keyName = \APP\plugins\generic\openid\OpenIDPlugin::ID_TOKEN_NAME; + } + if (method_exists($session, 'getSessionVar')) { + return $session->getSessionVar($keyName); + } + if (method_exists($session, 'get')) { + return $session->get($keyName); + } + } catch (\Throwable $e) { + error_log('[ojsWpSsoSubscription] session read failed: ' . $e->getMessage()); + } + return null; + } + + /** + * JWT id_token for claim parsing: pkp/openid stores the id_token encrypted + * via OpenIDPlugin::encryptOrDecrypt; without decrypt, base64 decode fails. + */ + private function getDecryptedIdTokenString() + { + $raw = $this->getSessionIdTokenRaw(); + if ($raw === null || $raw === '') { + return null; + } + if (!class_exists('\\APP\\plugins\\generic\\openid\\OpenIDPlugin')) { + return is_string($raw) ? $raw : null; + } + $request = Application::get()->getRequest(); + $context = $request->getContext(); + $contextId = $context ? (int) $context->getId() : \PKP\core\PKPApplication::CONTEXT_SITE; + + $openid = PluginRegistry::getPlugin('generic', 'openid'); + if (!$openid) { + $loadCtx = $context ? (int) $context->getId() : \PKP\core\PKPApplication::CONTEXT_SITE; + $openid = PluginRegistry::loadPlugin('generic', 'openid', $loadCtx); + } + if (!$openid) { + $openid = PluginRegistry::loadPlugin('generic', 'openid', 1); + } + if (!$openid instanceof \APP\plugins\generic\openid\OpenIDPlugin) { + return is_string($raw) ? $raw : null; + } + $dec = \APP\plugins\generic\openid\OpenIDPlugin::encryptOrDecrypt($openid, $contextId, $raw, false); + if (is_string($dec) && substr_count($dec, '.') >= 2) { + return $dec; + } + + return is_string($raw) ? $raw : null; + } + + /** + * Optional access_token in session. Stock pkp/openid does not persist it; if you + * patch OpenID to $session->setSessionVar('access_token', $token['access_token']), userinfo + * will be called with the correct Bearer (RFC 6750). + */ + private function getSessionAccessTokenRaw() + { + try { + $session = Application::get()->getRequest()->getSession(); + if (!$session) { + return null; + } + if (method_exists($session, 'getSessionVar')) { + $v = $session->getSessionVar('access_token'); + } elseif (method_exists($session, 'get')) { + $v = $session->get('access_token'); + } else { + $v = null; + } + return (is_string($v) && $v !== '') ? $v : null; + } catch (\Throwable $e) { + error_log('[ojsWpSsoSubscription] access_token session read failed: ' . $e->getMessage()); + } + return null; + } + + /** + * UserInfo (OIDC): try Bearer = access_token first, then id_token (some IdPs accept it). + */ + private function fetchUserinfoClaims($userinfoUrl, ?string $accessToken, ?string $idToken) + { + if (empty($userinfoUrl)) { + return null; + } + foreach (array_unique(array_filter([$accessToken, $idToken])) as $bearer) { + $data = $this->fetchUserinfoClaimsWithBearer($userinfoUrl, $bearer); + if (is_array($data)) { + return $data; + } + } + return null; + } + + private function fetchUserinfoClaimsWithBearer($userinfoUrl, $bearer) + { + $ch = curl_init($userinfoUrl); + $headers = ['Accept: application/json']; + if ($bearer !== null && $bearer !== '') { + $headers[] = 'Authorization: Bearer ' . $bearer; + } + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 5, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_HTTPHEADER => $headers, + ]); + $response = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($httpCode !== 200 || !$response) { + return null; + } + $data = json_decode($response, true); + return is_array($data) ? $data : null; + } + + /** + * Apply OIDC subscription claim to the DB for this user/journal. + * + * @param bool $respectThrottle When false, always attempt claim read + DB update + * (used when gating PDF access via IssueAction::subscribedUser). + */ + private function syncSubscriptionFromOidcClaims(\PKP\user\User $user, int $contextId, bool $respectThrottle): void + { + $typeId = (int) ($this->getSetting($contextId, 'subscriptionTypeId') ?: 0); + if (!$typeId) { + return; + } + + /** @var \IndividualSubscriptionDAO $subscriptionDao */ + $subscriptionDao = DAORegistry::getDAO('IndividualSubscriptionDAO'); + $existing = $subscriptionDao->getByUserIdForJournal($user->getId(), $contextId); + + if ($respectThrottle) { + $interval = (int) ($this->getSetting($contextId, 'revalidateInterval') ?: 300); + $lastCheckKey = 'lastCheck_' . $user->getId(); + $lastCheck = (int) ($this->getSetting($contextId, $lastCheckKey) ?: 0); + $hasActive = $subscriptionDao->isValidIndividualSubscription( + (int) $user->getId(), + (int) $contextId + ); + if ($hasActive && $interval > 0 && $lastCheck && (time() - $lastCheck) < $interval) { + return; + } + } + + $idToken = $this->getDecryptedIdTokenString(); + $claims = $this->decodeIdTokenClaims($idToken); + if (!is_array($claims)) { + $claims = []; + } + + $claimName = $this->getSetting($contextId, 'claimName') ?: 'subscription_active'; + $accessToken = $this->getSessionAccessTokenRaw(); + if (!array_key_exists($claimName, $claims)) { + $userinfoUrl = $this->getSetting($contextId, 'userinfoUrl'); + $ui = $this->fetchUserinfoClaims($userinfoUrl, $accessToken, $idToken); + if (is_array($ui)) { + $claims = array_merge($claims, $ui); + } + } + + if (!array_key_exists($claimName, $claims)) { + return; + } + + $lastCheckKey = 'lastCheck_' . $user->getId(); + $this->updateSetting($contextId, $lastCheckKey, time()); + + $duration = (int) ($this->getSetting($contextId, 'subscriptionDuration') ?: 12); + $isActive = $this->claimIsTruthy($claims[$claimName]); + + if ($isActive) { + if (!$existing) { + $sub = $subscriptionDao->newDataObject(); + $sub->setJournalId($contextId); + $sub->setUserId($user->getId()); + $sub->setTypeId($typeId); + $sub->setStatus(SUBSCRIPTION_STATUS_ACTIVE); + $sub->setDateStart(date('Y-m-d')); + $sub->setDateEnd(date('Y-m-d', strtotime("+{$duration} months"))); + $subscriptionDao->insertObject($sub); + error_log('[ojsWpSsoSubscription] created subscription for user_id=' . $user->getId()); + } else { + // Refresh dates whenever the OIDC claim is still true. Otherwise a row + // can stay STATUS_ACTIVE with date_end in the past and + // isValidIndividualSubscription() keeps denying PDF access. + $wasInactive = ((int) $existing->getStatus() !== SUBSCRIPTION_STATUS_ACTIVE); + $existing->setStatus(SUBSCRIPTION_STATUS_ACTIVE); + $existing->setDateEnd(date('Y-m-d', strtotime("+{$duration} months"))); + $subscriptionDao->updateObject($existing); + error_log($wasInactive + ? '[ojsWpSsoSubscription] reactivated subscription for user_id=' . $user->getId() + : '[ojsWpSsoSubscription] renewed subscription end date for user_id=' . $user->getId()); + } + } elseif ($existing && (int) $existing->getStatus() === SUBSCRIPTION_STATUS_ACTIVE) { + $existing->setStatus(SUBSCRIPTION_STATUS_NEEDS_APPROVAL); + $subscriptionDao->updateObject($existing); + error_log('[ojsWpSsoSubscription] deactivated subscription for user_id=' . $user->getId()); + } + } + + /** + * Main hook: every request, possibly sync the user's subscription based + * on the subscription claim. Rate-limited via revalidateInterval, but + * bypassed if the user has no active subscription yet (first-time sync). + */ + public function validateSubscriptionOnRequest($hookName, $args) + { + $request = Application::get()->getRequest(); + $user = $request->getUser(); + if (!$user) { + return false; + } + + $context = $request->getContext(); + $contextId = $context ? $context->getId() : 0; + if (!$contextId) { + return false; + } + + if (!(int) ($this->getSetting($contextId, 'subscriptionTypeId') ?: 0)) { + return false; + } + + // LoadHandler passes [&$page, &$op, &$sourceFile, &$handler] as a single array + // (see PKPPageRouter). Always re-sync before PDF/issue gates on these ops. + $page = is_array($args) && isset($args[0]) ? $args[0] : null; + $op = is_array($args) && isset($args[1]) ? $args[1] : null; + $subscriptionGate = in_array($page, ['issue', 'article'], true) + && in_array($op, ['view', 'download'], true); + + $this->syncSubscriptionFromOidcClaims($user, $contextId, !$subscriptionGate); + + return false; + } + + /** + * After core computes subscription access, sync from OIDC without throttle + * and grant access if the DB row is now valid (fixes PDF gate on first hit). + */ + public function hookIssueActionSubscribedUser($hookName, $args) + { + if (!isset($args[0], $args[1]) || !$args[0] instanceof \PKP\user\User || !$args[1]) { + return false; + } + + if (!empty($args[4])) { + return false; + } + + $user = $args[0]; + $journal = $args[1]; + $contextId = (int) $journal->getId(); + if (!$contextId || !(int) ($this->getSetting($contextId, 'subscriptionTypeId') ?: 0)) { + return false; + } + + $this->syncSubscriptionFromOidcClaims($user, $contextId, false); + + /** @var \IndividualSubscriptionDAO $subscriptionDao */ + $subscriptionDao = DAORegistry::getDAO('IndividualSubscriptionDAO'); + if ($subscriptionDao->isValidIndividualSubscription((int) $user->getId(), $contextId)) { + $args[4] = true; + } + + return false; + } + + /** + * Block the core redirect from about/subscriptions to index when PayPal/Stripe + * is off (SSO-only). Aborting the redirect returns control to AboutHandler so + * subscriptions.tpl can be displayed. + */ + public function hookRequestRedirect($hookName, $args) + { + if (!isset($args[0]) || $args[0] === '') { + return Hook::CONTINUE; + } + + $request = Application::get()->getRequest(); + if ($request->getRequestedPage() !== 'about' || $request->getRequestedOp() !== 'subscriptions') { + return Hook::CONTINUE; + } + + $context = $request->getContext(); + if (!$context || !(int) ($this->getSetting($context->getId(), 'subscriptionTypeId') ?: 0)) { + return Hook::CONTINUE; + } + + return Hook::ABORT; + } + + // ------------------------------------------------------------------------- + // Hide subscription issues + // ------------------------------------------------------------------------- + + /** + * Must match OJS core: IssueAction::subscribedUser() uses + * IndividualSubscriptionDAO::isValidIndividualSubscription(), which also + * requires subscription_types.format to be Online (1) or Print+Online (17). + * A row with status ACTIVE but format = Print-only (16) shows the issue + * (if we only checked status) but hides PDF galleys in the UI. + */ + private function userHasSubscription($userId, $contextId) + { + /** @var \IndividualSubscriptionDAO $subscriptionDao */ + $subscriptionDao = DAORegistry::getDAO('IndividualSubscriptionDAO'); + + return $subscriptionDao->isValidIndividualSubscription( + (int) $userId, + (int) $contextId + ); + } + + public function filterIssueArchive($hookName, $args) + { + if (!isset($args[0]) || !is_array($args[0])) { + return false; + } + + $request = Application::get()->getRequest(); + $user = $request->getUser(); + $context = $request->getContext(); + $contextId = $context ? $context->getId() : 0; + + if ($user && $this->userHasSubscription($user->getId(), $contextId)) { + return false; + } + + $args[0] = array_filter($args[0], function ($issue) { + return $issue->getAccessStatus() != ISSUE_ACCESS_SUBSCRIPTION; + }); + + return false; + } + + /** + * Homepage: hide current issue if subscription-only and user has no access. + * Issue pages always use core OJS (TOC + locked or open galleys); blocking the + * whole issue for logged-in users without a synced DB row was confusing and + * differed from the old (never-fired) IssueHandler::view hook. + */ + public function filterTemplateManagerDisplay($hookName, $args) + { + if (!isset($args[1])) { + return false; + } + + $templateFile = $args[1]; + $templateMgr = $args[0]; + + if (strpos($templateFile, 'frontend/pages/index') === false) { + return false; + } + + $request = Application::get()->getRequest(); + $user = $request->getUser(); + $context = $request->getContext(); + $contextId = $context ? $context->getId() : 0; + + if ($user && $this->userHasSubscription($user->getId(), $contextId)) { + return false; + } + + $issue = $templateMgr->getTemplateVars('issue'); + if ($issue && $issue->getAccessStatus() == ISSUE_ACCESS_SUBSCRIPTION) { + $templateMgr->assign('issue', null); + } + + return false; + } +} diff --git a/OjsWpSsoSubscriptionSettingsForm.php b/OjsWpSsoSubscriptionSettingsForm.php new file mode 100644 index 0000000..983daf8 --- /dev/null +++ b/OjsWpSsoSubscriptionSettingsForm.php @@ -0,0 +1,129 @@ +plugin = $plugin; + $this->contextId = $contextId; + + parent::__construct($plugin->getTemplateResource('settings.tpl')); + + $this->addCheck(new FormValidatorPost($this)); + $this->addCheck(new FormValidatorCSRF($this)); + } + + public function initData() + { + foreach (self::$settingKeys as $key) { + $this->setData($key, $this->plugin->getSetting($this->contextId, $key)); + } + } + + public function readInputData() + { + $this->readUserVars(self::$settingKeys); + } + + public function validate($callHooks = true) + { + if (!parent::validate($callHooks)) { + return false; + } + + $contextId = (int) $this->contextId; + if ($contextId <= 0 || $contextId === PKPApplication::CONTEXT_SITE) { + return true; + } + + $typeId = (int) $this->getData('subscriptionTypeId'); + if ($typeId <= 0) { + return true; + } + + /** @var \SubscriptionTypeDAO $typeDao */ + $typeDao = DAORegistry::getDAO('SubscriptionTypeDAO'); + $type = $typeDao->getById($typeId); + if (!$type || (int) $type->getJournalId() !== $contextId) { + $this->addError( + 'subscriptionTypeId', + __('plugins.generic.ojsWpSsoSubscription.error.subscriptionTypeInvalid') + ); + + return false; + } + + $fmt = (int) $type->getFormat(); + if (!in_array($fmt, [ + SubscriptionType::SUBSCRIPTION_TYPE_FORMAT_ONLINE, + SubscriptionType::SUBSCRIPTION_TYPE_FORMAT_PRINT_ONLINE, + ], true)) { + $this->addError( + 'subscriptionTypeId', + __('plugins.generic.ojsWpSsoSubscription.error.subscriptionTypeNeedsOnline') + ); + + return false; + } + + return true; + } + + public function fetch($request, $template = null, $display = false) + { + $templateMgr = \APP\template\TemplateManager::getManager($request); + $templateMgr->assign('pluginName', $this->plugin->getName()); + return parent::fetch($request, $template, $display); + } + + public function execute(...$functionArgs) + { + $sanitized = []; + $sanitized['claimName'] = preg_match('/^[a-z_][a-z0-9_]*$/', $this->getData('claimName')) + ? $this->getData('claimName') + : 'subscription_active'; + + $sanitized['subscriptionTypeId'] = max(0, (int) $this->getData('subscriptionTypeId')); + $sanitized['subscriptionDuration'] = max(1, (int) ($this->getData('subscriptionDuration') ?: 12)); + $sanitized['autoExpire'] = $this->getData('autoExpire') ? '1' : ''; + $sanitized['revalidateInterval'] = max(0, (int) ($this->getData('revalidateInterval') ?: 300)); + $sanitized['userinfoUrl'] = filter_var($this->getData('userinfoUrl'), FILTER_VALIDATE_URL) ?: ''; + + foreach ($sanitized as $key => $value) { + $this->plugin->updateSetting($this->contextId, $key, $value); + } + + parent::execute(...$functionArgs); + } +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..b03fd63 --- /dev/null +++ b/index.php @@ -0,0 +1,16 @@ + Subscriptions > Subscription Types and enable Online or Print and Online, or pick another type. OJS only grants PDF access when format is Online or Print+Online." + +msgid "plugins.generic.ojsWpSsoSubscription.settings.subscriptionDuration" +msgstr "Subscription Duration (months)" + +msgid "plugins.generic.ojsWpSsoSubscription.settings.subscriptionDuration.description" +msgstr "Number of months for new or renewed subscriptions (default: 12)." + +msgid "plugins.generic.ojsWpSsoSubscription.settings.revalidateInterval" +msgstr "Revalidation Interval (seconds)" + +msgid "plugins.generic.ojsWpSsoSubscription.settings.revalidateInterval.description" +msgstr "How often to re-check the OIDC claim for logged-in users (0 = disabled, default: 300)." + +msgid "plugins.generic.ojsWpSsoSubscription.settings.userinfoUrl" +msgstr "OIDC UserInfo URL" + +msgid "plugins.generic.ojsWpSsoSubscription.settings.userinfoUrl.description" +msgstr "Full URL of the OIDC provider UserInfo endpoint for periodic revalidation." + +msgid "plugins.generic.ojsWpSsoSubscription.settings.autoExpire" +msgstr "Auto-expire" + +msgid "plugins.generic.ojsWpSsoSubscription.settings.autoExpire.label" +msgstr "Deactivate subscription when the OIDC claim is false" + +msgid "plugins.generic.ojsWpSsoSubscription.subscriptionRequired.title" +msgstr "Subscription Required" + +msgid "plugins.generic.ojsWpSsoSubscription.subscriptionRequired.message" +msgstr "This issue requires an active subscription. Please contact the site administrator to activate your subscription." diff --git a/locale/it/locale.po b/locale/it/locale.po new file mode 100644 index 0000000..cf44ece --- /dev/null +++ b/locale/it/locale.po @@ -0,0 +1,63 @@ +msgid "" +msgstr "" +"Project-Id-Version: ojsWpSsoSubscription 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: it\n" + +msgid "plugins.generic.ojsWpSsoSubscription.displayName" +msgstr "WP SSO Subscription" + +msgid "plugins.generic.ojsWpSsoSubscription.description" +msgstr "Sincronizza le sottoscrizioni individuali OJS con un claim OIDC (es. da WordPress). Nasconde i numeri protetti ai non sottoscrittori." + +msgid "plugins.generic.ojsWpSsoSubscription.settings.title" +msgstr "Impostazioni WP SSO Subscription" + +msgid "plugins.generic.ojsWpSsoSubscription.settings.claimName" +msgstr "Nome claim OIDC" + +msgid "plugins.generic.ojsWpSsoSubscription.settings.claimName.description" +msgstr "Nome del claim booleano nel token OIDC che indica una sottoscrizione attiva (default: subscription_active)." + +msgid "plugins.generic.ojsWpSsoSubscription.settings.subscriptionTypeId" +msgstr "ID tipo sottoscrizione" + +msgid "plugins.generic.ojsWpSsoSubscription.settings.subscriptionTypeId.description" +msgstr "ID del tipo di sottoscrizione individuale OJS da usare per la creazione. Il tipo deve includere l'accesso online (non solo stampa), altrimenti OJS nasconde i PDF anche con sottoscrizione attiva nel database." + +msgid "plugins.generic.ojsWpSsoSubscription.error.subscriptionTypeInvalid" +msgstr "Il tipo di sottoscrizione selezionato non appartiene a questa rivista o non e' stato trovato." + +msgid "plugins.generic.ojsWpSsoSubscription.error.subscriptionTypeNeedsOnline" +msgstr "Questo tipo e' solo cartaceo o non include l'accesso online. Modificalo in Impostazioni rivista > Sottoscrizioni > Tipi di sottoscrizione e abilita \"Online\" o \"Stampa e online\", oppure scegli un altro tipo. OJS abilita i PDF solo con formato Online o Stampa+Online." + +msgid "plugins.generic.ojsWpSsoSubscription.settings.subscriptionDuration" +msgstr "Durata sottoscrizione (mesi)" + +msgid "plugins.generic.ojsWpSsoSubscription.settings.subscriptionDuration.description" +msgstr "Numero di mesi per sottoscrizioni nuove o rinnovate (default: 12)." + +msgid "plugins.generic.ojsWpSsoSubscription.settings.revalidateInterval" +msgstr "Intervallo di rivalidazione (secondi)" + +msgid "plugins.generic.ojsWpSsoSubscription.settings.revalidateInterval.description" +msgstr "Ogni quanti secondi ricontrollare il claim OIDC per utenti autenticati (0 = disabilitato, default: 300)." + +msgid "plugins.generic.ojsWpSsoSubscription.settings.userinfoUrl" +msgstr "URL UserInfo OIDC" + +msgid "plugins.generic.ojsWpSsoSubscription.settings.userinfoUrl.description" +msgstr "URL completo dell'endpoint UserInfo del provider OIDC per la rivalidazione periodica." + +msgid "plugins.generic.ojsWpSsoSubscription.settings.autoExpire" +msgstr "Scadenza automatica" + +msgid "plugins.generic.ojsWpSsoSubscription.settings.autoExpire.label" +msgstr "Disattiva la sottoscrizione quando il claim OIDC e' false" + +msgid "plugins.generic.ojsWpSsoSubscription.subscriptionRequired.title" +msgstr "Sottoscrizione richiesta" + +msgid "plugins.generic.ojsWpSsoSubscription.subscriptionRequired.message" +msgstr "Questo numero richiede una sottoscrizione attiva. Contatta l'amministratore del sito per attivare la tua sottoscrizione." diff --git a/setup-ojs-subscriptions.php b/setup-ojs-subscriptions.php new file mode 100644 index 0000000..93dad8a --- /dev/null +++ b/setup-ojs-subscriptions.php @@ -0,0 +1,252 @@ +OJS Subscription Setup"; + +// --- Step 0: Read config --- +echo "

Step 0: Lettura config.inc.php

"; +$configPath = __DIR__ . '/config.inc.php'; +if (!file_exists($configPath)) { + die("

config.inc.php non trovato in " . htmlspecialchars(__DIR__) . "

"); +} + +$config = parse_ini_file($configPath, true); +if (!$config) { + die("

parse_ini_file() fallita. Il file potrebbe avere un formato non standard.

"); +} + +$dbSection = $config['database'] ?? null; +if (!$dbSection) { + echo "

Sezione [database] non trovata nel config. Sezioni trovate: " . htmlspecialchars(implode(', ', array_keys($config))) . "

"; + die(); +} + +$dbHost = $dbSection['host'] ?? 'localhost'; +$dbUser = $dbSection['username'] ?? ''; +$dbPass = $dbSection['password'] ?? ''; +$dbName = $dbSection['name'] ?? ''; + +echo "

Host: " . htmlspecialchars($dbHost) . ", DB: " . htmlspecialchars($dbName) . ", User: " . htmlspecialchars($dbUser) . "

"; + +$db = new mysqli($dbHost, $dbUser, $dbPass, $dbName); +if ($db->connect_error) { + die("

Connessione DB fallita: " . htmlspecialchars($db->connect_error) . "

"); +} +$db->set_charset('utf8'); +echo "

Connessione DB riuscita.

"; + +// --- Step 0b: Check table structure --- +echo "

Step 0b: Verifica struttura tabelle

"; + +$tables = ['journal_settings', 'subscription_types', 'subscription_type_settings', 'issues', 'custom_issue_orders']; +foreach ($tables as $t) { + $r = $db->query("SHOW TABLES LIKE '{$t}'"); + $exists = $r && $r->num_rows > 0; + echo "

Tabella {$t}: " . ($exists ? 'esiste' : 'NON esiste') . "

"; + if (!$exists && in_array($t, ['subscription_types', 'journal_settings', 'issues'])) { + die("

Tabella critica mancante. Verifica che OJS sia installato correttamente.

"); + } +} + +// Check subscription_types columns +$r = $db->query("DESCRIBE subscription_types"); +if ($r) { + $cols = []; + while ($row = $r->fetch_assoc()) { + $cols[] = $row['Field']; + } + echo "

Colonne subscription_types: " . htmlspecialchars(implode(', ', $cols)) . "

"; +} + +// --- Step 1: publishingMode --- +echo "

Step 1: publishingMode = Subscription

"; + +$r = $db->query("SELECT setting_value FROM journal_settings WHERE journal_id = 1 AND setting_name = 'publishingMode'"); +if ($r && $r->num_rows > 0) { + $current = $r->fetch_assoc()['setting_value']; + echo "

Valore attuale: {$current}

"; + if ($current !== '1') { + $db->query("UPDATE journal_settings SET setting_value = '1' WHERE journal_id = 1 AND setting_name = 'publishingMode'"); + echo "

Aggiornato a 1 (Subscription). Righe: " . $db->affected_rows . "

"; + } else { + echo "

Gia' impostato correttamente.

"; + } +} else { + $db->query("INSERT INTO journal_settings (journal_id, setting_name, setting_value, setting_type) VALUES (1, 'publishingMode', '1', 'int')"); + echo "

Inserito publishingMode = 1. Insert ID: " . $db->insert_id . "

"; +} + +if ($db->error) { + echo "

Errore MySQL step 1: " . htmlspecialchars($db->error) . "

"; +} + +// --- Step 2: Subscription Type --- +echo "

Step 2: Tipo di sottoscrizione individuale

"; + +$result = $db->query("SELECT type_id FROM subscription_types WHERE journal_id = 1 AND institutional = 0 LIMIT 1"); +if ($db->error) { + echo "

Errore query subscription_types: " . htmlspecialchars($db->error) . "

"; + // Try without institutional filter + echo "

Riprovo senza filtro institutional...

"; + $result = $db->query("SELECT type_id FROM subscription_types WHERE journal_id = 1 LIMIT 1"); + if ($db->error) { + echo "

Errore: " . htmlspecialchars($db->error) . "

"; + } +} + +$typeId = null; +if ($result && $result->num_rows > 0) { + $row = $result->fetch_assoc(); + $typeId = $row['type_id']; + echo "

Tipo sottoscrizione gia' esistente, type_id = {$typeId}

"; +} else { + echo "

Nessun tipo trovato, ne creo uno nuovo...

"; + + // Check actual columns available + $colsResult = $db->query("DESCRIBE subscription_types"); + $availableCols = []; + while ($c = $colsResult->fetch_assoc()) { + $availableCols[] = $c['Field']; + } + + // Build INSERT based on available columns + $insertCols = ['journal_id']; + $insertVals = ['1']; + + $optionalCols = [ + 'cost' => '0.00', + 'currency_code_alpha' => "'EUR'", + 'duration' => '12', + 'format' => '1', + 'institutional' => '0', + 'membership' => '0', + 'disable_public_display' => '0', + ]; + + foreach ($optionalCols as $col => $val) { + if (in_array($col, $availableCols)) { + $insertCols[] = $col; + $insertVals[] = $val; + } + } + + $sql = "INSERT INTO subscription_types (" . implode(', ', $insertCols) . ") VALUES (" . implode(', ', $insertVals) . ")"; + echo "

SQL: " . htmlspecialchars($sql) . "

"; + + $db->query($sql); + if ($db->error) { + echo "

Errore INSERT subscription_types: " . htmlspecialchars($db->error) . "

"; + } else { + $typeId = $db->insert_id; + echo "

Creato type_id = {$typeId}

"; + + // Add names + $stmtIt = $db->prepare("INSERT INTO subscription_type_settings (type_id, locale, setting_name, setting_value, setting_type) VALUES (?, 'it', 'name', 'Socio GuIT – Annuale', 'string')"); + if ($stmtIt) { + $stmtIt->bind_param('i', $typeId); + $stmtIt->execute(); + if ($stmtIt->error) echo "

Errore name IT: " . htmlspecialchars($stmtIt->error) . "

"; + $stmtIt->close(); + } else { + echo "

Prepare fallita (name IT): " . htmlspecialchars($db->error) . "

"; + } + + $stmtEn = $db->prepare("INSERT INTO subscription_type_settings (type_id, locale, setting_name, setting_value, setting_type) VALUES (?, 'en', 'name', 'GuIT Member – Annual', 'string')"); + if ($stmtEn) { + $stmtEn->bind_param('i', $typeId); + $stmtEn->execute(); + if ($stmtEn->error) echo "

Errore name EN: " . htmlspecialchars($stmtEn->error) . "

"; + $stmtEn->close(); + } else { + echo "

Prepare fallita (name EN): " . htmlspecialchars($db->error) . "

"; + } + } +} + +// --- Step 2b: formato tipo = Online (richiesto da OJS per i PDF) --- +echo "

Step 2b: Formato tipo sottoscrizione (Online o Stampa+Online)

"; +if ($typeId) { + $db->query( + "UPDATE subscription_types SET format = 1 WHERE journal_id = 1 AND type_id = " . (int) $typeId . + " AND institutional = 0 AND format NOT IN (1, 17)" + ); + echo "

Aggiornamento format per type_id={$typeId} (solo se non era gia' 1 o 17). Affected rows: " . (int) $db->affected_rows . "

"; + if ($db->error) { + echo "

Errore: " . htmlspecialchars($db->error) . "

"; + } + echo "

OJS 3.4: i PDF richiedono format=1 (online) o 17 (stampa+online); il solo stampa (16) nasconde i galley.

"; +} else { + echo "

Nessun type_id: salto.

"; +} + +// --- Step 3: Issue 36 (id=39) → subscription --- +echo "

Step 3: Issue 36 (id=39) → subscription required

"; + +$r = $db->query("SELECT issue_id, volume, number, year, published, access_status FROM issues WHERE journal_id = 1 ORDER BY issue_id DESC LIMIT 5"); +if ($r) { + echo "

Ultime 5 issue nel DB:

"; + while ($row = $r->fetch_assoc()) { + echo ""; + foreach ($row as $v) echo ""; + echo ""; + } + echo "
issue_idvolumenumberyearpublishedaccess_status
" . htmlspecialchars($v ?? '') . "
"; +} + +// OJS 3.4: ISSUE_ACCESS_SUBSCRIPTION = 2, ISSUE_ACCESS_OPEN = 1 +$db->query("UPDATE issues SET access_status = 2, published = 1 WHERE issue_id = 39 AND journal_id = 1"); +echo "

UPDATE issue_id=39 → access_status=2 (subscription): affected=" . $db->affected_rows . "

"; +if ($db->error) echo "

Errore: " . htmlspecialchars($db->error) . "

"; + +// --- Step 4: All others → open access --- +echo "

Step 4: Tutte le altre issue → open access

"; +$db->query("UPDATE issues SET access_status = 1 WHERE issue_id != 39 AND journal_id = 1"); +echo "

UPDATE → access_status=1 (open): affected=" . $db->affected_rows . "

"; +if ($db->error) echo "

Errore: " . htmlspecialchars($db->error) . "

"; + +// --- Step 5: custom_issue_orders --- +echo "

Step 5: custom_issue_orders

"; +$r = $db->query("SELECT COUNT(*) as cnt FROM custom_issue_orders WHERE issue_id = 39 AND journal_id = 1"); +if ($db->error) { + echo "

Tabella custom_issue_orders non disponibile (non critico): " . htmlspecialchars($db->error) . "

"; +} elseif ($r) { + $row = $r->fetch_assoc(); + if ($row['cnt'] == 0) { + $db->query("INSERT INTO custom_issue_orders (issue_id, journal_id, seq) VALUES (39, 1, 0)"); + echo "

Aggiunta issue 39 a custom_issue_orders

"; + } else { + echo "

Issue 39 gia' presente in custom_issue_orders

"; + } +} + +// --- Summary --- +echo "
"; +echo "

Riepilogo

"; +if ($typeId) { + echo "

Subscription Type ID: {$typeId}

"; + echo "

Usa questo valore nelle impostazioni del plugin OJS WP SSO Subscription.

"; +} else { + echo "

ATTENZIONE: non e' stato possibile creare o trovare un tipo di sottoscrizione. Controlla gli errori sopra.

"; +} + +// Clear OJS cache +$cleared = 0; +foreach (['cache/fc-*', 'cache/t_compile/*', 'cache/wc-*'] as $pattern) { + $files = glob(__DIR__ . '/' . $pattern); + if ($files) { + array_map('unlink', $files); + $cleared += count($files); + } +} +echo "

Cache OJS svuotata: {$cleared} file rimossi.

"; + +echo "

ELIMINA QUESTO FILE IMMEDIATAMENTE!

"; +$db->close(); diff --git a/templates/settings.tpl b/templates/settings.tpl new file mode 100644 index 0000000..e95a6b2 --- /dev/null +++ b/templates/settings.tpl @@ -0,0 +1,44 @@ + + +
+ {csrf} + + {fbvFormArea id="ojsWpSsoSubscriptionSettingsArea" title="plugins.generic.ojsWpSsoSubscription.settings.title"} + + {fbvFormSection title="plugins.generic.ojsWpSsoSubscription.settings.claimName" description="plugins.generic.ojsWpSsoSubscription.settings.claimName.description"} + {fbvElement type="text" id="claimName" value=$claimName size=$fbvStyles.size.MEDIUM} + {/fbvFormSection} + + {fbvFormSection title="plugins.generic.ojsWpSsoSubscription.settings.subscriptionTypeId" description="plugins.generic.ojsWpSsoSubscription.settings.subscriptionTypeId.description"} + {fbvElement type="text" id="subscriptionTypeId" value=$subscriptionTypeId size=$fbvStyles.size.SMALL} + {/fbvFormSection} + + {fbvFormSection title="plugins.generic.ojsWpSsoSubscription.settings.subscriptionDuration" description="plugins.generic.ojsWpSsoSubscription.settings.subscriptionDuration.description"} + {fbvElement type="text" id="subscriptionDuration" value=$subscriptionDuration size=$fbvStyles.size.SMALL} + {/fbvFormSection} + + {fbvFormSection title="plugins.generic.ojsWpSsoSubscription.settings.revalidateInterval" description="plugins.generic.ojsWpSsoSubscription.settings.revalidateInterval.description"} + {fbvElement type="text" id="revalidateInterval" value=$revalidateInterval size=$fbvStyles.size.SMALL} + {/fbvFormSection} + + {fbvFormSection title="plugins.generic.ojsWpSsoSubscription.settings.userinfoUrl" description="plugins.generic.ojsWpSsoSubscription.settings.userinfoUrl.description"} + {fbvElement type="text" id="userinfoUrl" value=$userinfoUrl size=$fbvStyles.size.LARGE} + {/fbvFormSection} + + {fbvFormSection title="plugins.generic.ojsWpSsoSubscription.settings.autoExpire" list=true} + {fbvElement type="checkbox" id="autoExpire" checked=$autoExpire label="plugins.generic.ojsWpSsoSubscription.settings.autoExpire.label"} + {/fbvFormSection} + + {/fbvFormArea} + + {fbvFormButtons submitText="common.save"} +
diff --git a/templates/subscriptionRequired.tpl b/templates/subscriptionRequired.tpl new file mode 100644 index 0000000..c13636e --- /dev/null +++ b/templates/subscriptionRequired.tpl @@ -0,0 +1,10 @@ +{include file="frontend/components/header.tpl" pageTitleTranslated=$pageTitle} + +
+ +
+ +{include file="frontend/components/footer.tpl"} diff --git a/version.xml b/version.xml new file mode 100644 index 0000000..2ada584 --- /dev/null +++ b/version.xml @@ -0,0 +1,10 @@ + + + + ojsWpSsoSubscription + plugins.generic + 1.0.0.0 + 2026-04-16 + 0 + OjsWpSsoSubscriptionPlugin +