├── .distignore ├── .editorconfig ├── .github └── FUNDING.yml ├── .gitignore ├── .jshintrc ├── .phpcs.xml.dist ├── CHANGELOG.md ├── README.md ├── assets ├── css │ └── admin.css └── js │ ├── access.js │ ├── admin.js │ ├── components │ ├── access-table.js │ ├── api-key-form.js │ ├── icons.js │ ├── package-selector.js │ ├── package-table.js │ ├── release-actions.js │ ├── releases.js │ ├── repository.js │ └── sidebar.js │ ├── data │ ├── access.js │ └── packages.js │ ├── repository.js │ ├── utils │ └── index.js │ └── vendor │ └── htm.module.js ├── bin └── archive ├── composer.json ├── docs ├── alternatives.md ├── composer.md ├── images │ ├── api-keys.png │ ├── repository-setup.png │ ├── repository.png │ ├── revoke-api-keys.png │ └── settings.png ├── index.md ├── installation.md ├── integrations.md ├── logging.md ├── mu-plugins.md ├── security.md ├── settings.md ├── setup.md ├── troubleshooting.md └── workflows │ ├── central-server.md │ ├── commercial-vendors.md │ ├── continuous-integration.md │ └── production.md ├── languages └── satispress.pot ├── package.json ├── phpunit.xml.dist ├── satispress.php ├── src ├── Archiver.php ├── Authentication │ ├── ApiKey │ │ ├── ApiKey.php │ │ ├── ApiKeyRepository.php │ │ ├── Factory.php │ │ ├── Repository.php │ │ └── Server.php │ ├── Server.php │ └── UnauthorizedServer.php ├── Capabilities.php ├── Composable.php ├── ComposerVersionParser.php ├── Container.php ├── Exception │ ├── AuthenticationException.php │ ├── FileDownloadFailed.php │ ├── FileNotFound.php │ ├── FileOperationFailed.php │ ├── HttpException.php │ ├── InvalidFileName.php │ ├── InvalidPackageArtifact.php │ ├── InvalidReleaseSource.php │ ├── InvalidReleaseVersion.php │ ├── PackageNotInstalled.php │ └── SatispressException.php ├── HTTP │ ├── Request.php │ ├── Response.php │ └── ResponseBody │ │ ├── ErrorBody.php │ │ ├── FileBody.php │ │ ├── JsonBody.php │ │ ├── NullBody.php │ │ └── ResponseBody.php ├── Htaccess.php ├── Integration │ ├── EnvatoMarket.php │ └── Members.php ├── Logger.php ├── Package.php ├── PackageFactory.php ├── PackageType │ ├── BasePackage.php │ ├── PackageBuilder.php │ ├── Plugin.php │ ├── PluginBuilder.php │ ├── Theme.php │ └── ThemeBuilder.php ├── Plugin.php ├── Provider │ ├── Activation.php │ ├── AdminAssets.php │ ├── Authentication.php │ ├── Capabilities.php │ ├── CustomVendor.php │ ├── Deactivation.php │ ├── HealthCheck.php │ ├── PackageArchiver.php │ ├── REST.php │ ├── RequestHandler.php │ ├── RewriteRules.php │ └── Upgrade.php ├── REST │ ├── ApiKeysController.php │ ├── InstalledPackagesController.php │ └── PackagesController.php ├── Release.php ├── ReleaseManager.php ├── Repository │ ├── AbstractRepository.php │ ├── CachedRepository.php │ ├── FilteredRepository.php │ ├── InstalledPlugins.php │ ├── InstalledThemes.php │ ├── MultiRepository.php │ └── PackageRepository.php ├── Route │ ├── Composer.php │ ├── Download.php │ └── Route.php ├── Screen │ ├── EditUser.php │ └── Settings.php ├── ServiceProvider.php ├── Storage │ ├── Local.php │ └── Storage.php ├── Transformer │ ├── ComposerPackageTransformer.php │ ├── ComposerRepositoryTransformer.php │ ├── PackageRepositoryTransformer.php │ └── PackageTransformer.php ├── Validator │ ├── ArtifactValidator.php │ ├── HiddenDirectoryValidator.php │ └── ZipValidator.php ├── VersionParser.php └── functions.php ├── tests └── phpunit │ ├── Fixture │ └── wp-content │ │ ├── plugins │ │ └── basic │ │ │ └── basic.php │ │ ├── themes │ │ └── ovation │ │ │ └── style.css │ │ └── uploads │ │ └── satispress │ │ └── packages │ │ ├── basic │ │ └── basic-1.0.0.zip │ │ └── validate │ │ ├── invalid-osx-zip.zip │ │ ├── invalid-zip.zip │ │ └── valid-zip.zip │ ├── Integration │ ├── PackageType │ │ └── ThemeTest.php │ ├── Provider │ │ └── AuthenticationTest.php │ ├── Repository │ │ └── InstalledPluginsTest.php │ ├── TestCase.php │ └── Validator │ │ ├── HiddenDirectoryValidatorTest.php │ │ └── ZipValidatorTest.php │ ├── Unit │ ├── PackageType │ │ ├── PackageBuilderTest.php │ │ ├── PackageReleasesTest.php │ │ ├── PackageTest.php │ │ ├── PluginReleasesTest.php │ │ └── PluginTest.php │ ├── TestCase.php │ └── Transformer │ │ └── ComposerPackageTransformerTest.php │ ├── bootstrap.php │ └── patchwork.json └── views ├── screen-settings.php └── tabs ├── access.php ├── composer.php ├── repository.php └── settings.php /.distignore: -------------------------------------------------------------------------------- 1 | bin 2 | composer.json 3 | composer.lock 4 | dist 5 | docs 6 | .DS_Store 7 | .editorconfig 8 | .git 9 | .gitignore 10 | .idea 11 | .jshintrc 12 | node_modules 13 | package.json 14 | package-lock.json 15 | phpcs.xml 16 | .phpcs.xml 17 | .phpcs.xml.dist 18 | .phpunit.result.cache 19 | phpunit.xml 20 | phpunit.xml.dist 21 | tests 22 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = tab 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | custom: ['https://paypal.me/bradyvercher'] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /dist/ 3 | /.idea 4 | /node_modules/ 5 | /vendor/ 6 | /composer.lock 7 | /package-lock.json 8 | /phpcs.xml 9 | /.phpcs.xml 10 | /.phpunit.result.cache 11 | /phpunit.xml 12 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "boss": true, 3 | "browser": true, 4 | "bitwise": true, 5 | "browser": true, 6 | "curly": true, 7 | "eqeqeq": true, 8 | "eqnull": true, 9 | "immed": true, 10 | "jquery": true, 11 | "latedef": true, 12 | "newcap": true, 13 | "noarg": true, 14 | "smarttabs": true, 15 | "sub": true, 16 | "trailing": true, 17 | "undef": true, 18 | "globals": { 19 | "console": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | Custom ruleset for the SatisPress plugin. 7 | 8 | 9 | 10 | 11 | 12 | . 13 | 15 | node_modules/ 16 | tests/ 17 | vendor/ 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 28 | 29 | 31 | 32 | 33 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 48 | 49 | 50 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | views/ 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SatisPress 2 | 3 | Facilitate modern best practices for managing WordPress websites by automating Composer support for private plugins and themes. 4 | 5 | ## What is Composer? 6 | 7 | When managing a WordPress site, multiple environments are usually needed for developing or testing code before deploying it to the live server. This requires being able to easily replicate the site and its dependencies between environments, which is where [Composer](https://getcomposer.org/) comes in. 8 | 9 | Composer allows for defining a project's dependencies, where they come from, how to access them, and then installing them from their source. 10 | 11 | For WordPress sites, dependencies are usually plugins and themes, and even WordPress itself. Essentially, a single file (`composer.json`) can be shared with another developer and they can rebuild the entire site structure from it. 12 | 13 | Composer connects to repositories — directories that tell it where to find dependencies (packages) and how they should be handled. 14 | 15 | [Packagist](https://packagist.org/) is the main Composer repository for PHP packages and [WordPress Packagist](https://wpackagist.org/) provides access to plugins and themes hosted in the directories on WordPress.org. 16 | 17 | ## What's the problem? 18 | 19 | Most commercial plugins and themes (also known as packages) aren't publicly available, so they can't be installed with Composer. 20 | 21 | Some common workarounds include: 22 | 23 | * Checking the plugin or theme in alongside custom project code in your version control system 24 | * Creating a separate private repository for each plugin or theme and manually updating it as new versions are released 25 | 26 | Neither option is ideal and can be a hassle to maintain over time. 27 | 28 | Furthermore, access is usually restricted with proprietary licensing schemes that make it difficult to download releases programmatically. 29 | 30 | ## How does SatisPress help? 31 | 32 | SatisPress creates a dynamically updated Composer repository that provides access to private plugins and themes and makes new releases available automatically. 33 | 34 | After installing SatisPress (it's a standard WordPress plugin): 35 | 36 | 1. Choose the plugins and themes that you want to manage 37 | 2. SatisPress zips the currently installed versions and stores them in a cache directory 38 | 3. When an update for a managed plugin or theme becomes available, SatisPress downloads and saves it alongside previously cached releases 39 | 4. A Composer repository is generated that can be included in your `composer.json` file to download any cached plugin or theme 40 | 41 | There are several possible workflows, but SatisPress allows you to manage private plugins and themes in a standard WordPress installation, leveraging the built-in update process to handle the myriad licensing schemes that would be impossible to account for outside of WordPress. 42 | 43 | It's the missing piece for managing WordPress websites with Composer. 44 | 45 | ## What if I don't use Composer? 46 | 47 | SatisPress can still benefit you since it makes releases downloadable directly from your admin panel, so you don't need to log in to vendors' sites to download updates. 48 | 49 | Oftentimes vendors only provide access to the latest release, so you're stuck if something breaks and you didn't save the previous version. With SatisPress, you can download previously cached releases to rollback if needed and compare the code to see what changed. 50 | 51 | ## Documentation 52 | 53 | For installation notes, information about usage, security, and more, see the [documentation](docs/index.md). 54 | 55 | ## Credits 56 | 57 | Created by [Brady Vercher](https://www.cedaro.com/) and supported by [Gary Jones](https://gamajo.com). 58 | -------------------------------------------------------------------------------- /assets/js/access.js: -------------------------------------------------------------------------------- 1 | import { data, element, html } from './utils/index.js'; 2 | import AccessTable from './components/access-table.js'; 3 | import './data/access.js'; 4 | 5 | const { useDispatch, useSelect } = data; 6 | const { createRoot, useEffect } = element; 7 | 8 | const { editedUserId } = _satispressAccessData; 9 | 10 | function App( { userId } ) { 11 | const { 12 | createApiKey, 13 | setUserId, 14 | revokeApiKey, 15 | } = useDispatch( 'satispress/access' ); 16 | 17 | const apiKeys = useSelect( ( select ) => { 18 | return select( 'satispress/access' ).getApiKeys() 19 | } ); 20 | 21 | useEffect( () => { 22 | setUserId( userId ); 23 | }, [ userId ] ); 24 | 25 | return html` 26 | <${ AccessTable } 27 | apiKeys=${ apiKeys } 28 | userId=${ userId } 29 | onCreateApiKey=${ ( name ) => createApiKey( name, userId ) } 30 | onRevokeApiKey=${ revokeApiKey } 31 | /> 32 | `; 33 | } 34 | 35 | const root = createRoot( document.getElementById( 'satispress-api-key-manager' ) ); 36 | root.render( html`<${ App } userId=${ editedUserId } />` ); 37 | -------------------------------------------------------------------------------- /assets/js/admin.js: -------------------------------------------------------------------------------- 1 | /* global jQuery:false */ 2 | 3 | ( function( window, $, undefined ) { 4 | 'use strict'; 5 | 6 | var $tabs = $( '.nav-tab-wrapper .nav-tab' ), 7 | $panels = $( '.satispress-tab-panel' ), 8 | updateTabs; 9 | 10 | updateTabs = function() { 11 | var hash = window.location.hash; 12 | 13 | $tabs.removeClass( 'nav-tab-active' ).filter( '[href="' + hash + '"]' ).addClass( 'nav-tab-active' ); 14 | $panels.removeClass( 'is-active' ).filter( hash ).addClass( 'is-active' ); 15 | 16 | if ( $tabs.filter( '.nav-tab-active' ).length < 1 ) { 17 | var href = $tabs.eq( 0 ).addClass( 'nav-tab-active' ).attr( 'href' ); 18 | $panels.removeClass( 'is-active' ).filter( href ).addClass( 'is-active' ); 19 | } 20 | }; 21 | 22 | updateTabs(); 23 | $( window ).on( 'hashchange', updateTabs ); 24 | 25 | // Scroll back to the top when a tab panel is reloaded or submitted. 26 | setTimeout(function() { 27 | if ( location.hash ) { 28 | window.scrollTo( 0, 1 ); 29 | } 30 | }, 1 ); 31 | 32 | } )( this, jQuery ); 33 | -------------------------------------------------------------------------------- /assets/js/components/access-table.js: -------------------------------------------------------------------------------- 1 | import { components, html, i18n } from '../utils/index.js'; 2 | import ApiKeyForm from './api-key-form.js'; 3 | import { moreVertical } from './icons.js'; 4 | 5 | const { DropdownMenu, Flex, FlexItem, MenuItem, TextControl } = components; 6 | const { __ } = i18n; 7 | 8 | const selectField = ( e ) => e.nativeEvent.target.select(); 9 | 10 | function AccessTable( props ) { 11 | const { 12 | apiKeys, 13 | onCreateApiKey, 14 | onRevokeApiKey, 15 | } = props; 16 | 17 | let body = html`${ __( 'Add an API Key to access the SatisPress repository.', 'satispress' ) }`; 18 | 19 | if ( apiKeys.length ) { 20 | body = apiKeys.map( ( item, index ) => { 21 | return html` 22 | <${ AccessTableRow } 23 | key=${ item.token } 24 | onRevokeApiKey=${ onRevokeApiKey } 25 | ...${ item } 26 | /> 27 | `; 28 | } ); 29 | } 30 | 31 | return html` 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | ${ body } 45 | 46 | 47 | 52 | 53 | 54 |
${ __( 'Name', 'satispress' ) }${ __( 'User', 'satispress' ) }${ __( 'API Key', 'satispress' ) }${ __( 'Last Used', 'satispress' ) }${ __( 'Created', 'satispress' ) }
48 | <${ ApiKeyForm } 49 | onSubmit=${ onCreateApiKey } 50 | /> 51 |
55 | `; 56 | }; 57 | 58 | function AccessTableRow( props ) { 59 | const { 60 | created, 61 | last_used, 62 | name, 63 | token, 64 | user, 65 | user_login, 66 | onRevokeApiKey, 67 | } = props; 68 | 69 | return html ` 70 | 71 | ${ name } 72 | ${ user_login } 73 | 74 | <${ TextControl } 75 | className="regular-text" 76 | value=${ token } 77 | readOnly 78 | onClick=${ selectField } 79 | __nextHasNoMarginBottom 80 | /> 81 | 82 | ${ last_used || '—' } 83 | ${ created } 84 | 85 | <${ DropdownMenu } 86 | label=${ __( 'Toggle dropdown', 'satispress' ) } 87 | icon=${ moreVertical } 88 | controls=${ [ 89 | { 90 | title: 'Revoke', 91 | onClick: () => { onRevokeApiKey( token, user ) } 92 | } 93 | ] } 94 | /> 95 | 96 | 97 | `; 98 | }; 99 | 100 | export default AccessTable; 101 | -------------------------------------------------------------------------------- /assets/js/components/api-key-form.js: -------------------------------------------------------------------------------- 1 | import { components, element, html, i18n } from '../utils/index.js'; 2 | 3 | const { Button, Flex, FlexItem, TextControl } = components; 4 | const { useState } = element; 5 | const { __ } = i18n; 6 | 7 | function ApiKeyForm( { onSubmit } ) { 8 | const [ name, setName ] = useState( '' ); 9 | 10 | const isEmpty = '' === name; 11 | 12 | const onClick = () => { 13 | onSubmit( name ); 14 | setName( '' ); 15 | }; 16 | 17 | return html` 18 | <${ Flex } justify="start"> 19 | <${ FlexItem }> 20 | <${ TextControl } 21 | label=${ __( 'API Key Name', 'satispress' ) } 22 | hideLabelFromVision 23 | placeholder=${ __( 'Name', 'satispress' ) } 24 | onChange=${ setName } 25 | value=${ name } 26 | __nextHasNoMarginBottom 27 | /> 28 | 29 | <${ FlexItem }> 30 | <${ Button } 31 | isPrimary=${ ! isEmpty } 32 | isSecondary=${ isEmpty } 33 | disabled="${ isEmpty && 'disabled' }" 34 | onClick=${ onClick } 35 | > 36 | ${ __( 'Create API Key', 'satispress' ) } 37 | 38 | 39 | 40 | `; 41 | }; 42 | 43 | export default ApiKeyForm; 44 | -------------------------------------------------------------------------------- /assets/js/components/icons.js: -------------------------------------------------------------------------------- 1 | import { html } from '../utils/index.js'; 2 | 3 | export const closeSmallIcon = html` 4 | 5 | 6 | `; 7 | 8 | export const moreVertical = html` 9 | 10 | 11 | `; 12 | -------------------------------------------------------------------------------- /assets/js/components/package-selector.js: -------------------------------------------------------------------------------- 1 | import { components, data, element, html, i18n } from '../utils/index.js'; 2 | import { closeSmallIcon } from './icons.js'; 3 | 4 | const { Button, CheckboxControl, Panel, PanelHeader, TabPanel, TextControl } = components; 5 | const { useSelect } = data; 6 | const { useState } = element; 7 | const { __ } = i18n; 8 | 9 | function PackageSelector( props ) { 10 | const { 11 | onAddPackage, 12 | onRemovePackage, 13 | onClose, 14 | packages, 15 | } = props; 16 | 17 | const [ searchTerm, setSearchTerm ] = useState( '' ); 18 | 19 | const installedPackages = { 20 | plugins: useSelect( ( select ) => select( 'satispress/packages' ).getPlugins() ), 21 | themes: useSelect( ( select ) => select( 'satispress/packages' ).getThemes() ), 22 | }; 23 | 24 | const packageExists = ( slug, type ) => { 25 | return !! packages.filter( item => slug === item.slug && type === item.type ).length; 26 | } 27 | 28 | const togglePackage = ( slug, type, add ) => { 29 | if ( add ) { 30 | onAddPackage( slug, type ); 31 | } else { 32 | onRemovePackage( slug, type ); 33 | } 34 | } 35 | 36 | const tabs = [ 37 | { 38 | name: 'plugins', 39 | title: __( 'Plugins', 'satispress' ), 40 | className: 'tab-plugins', 41 | }, 42 | { 43 | name: 'themes', 44 | title: __( 'Themes', 'satispress' ), 45 | className: 'tab-themes', 46 | } 47 | ]; 48 | 49 | const tabContent = ( tab ) => { 50 | const items = installedPackages[ tab.name ]; 51 | const searchTerms = searchTerm.toLowerCase().split(' ').filter( searchTerm => searchTerm ); 52 | const filteredItems = searchTerms.length === 0 ? items : items.filter( item => { 53 | return searchTerms.some( searchTerm => { 54 | const slugMatch = item.slug.toLowerCase().includes( searchTerm ); 55 | const nameMatch = item.name.toLowerCase().includes( searchTerm ); 56 | const authorMatch = item.author.toLowerCase().includes( searchTerm ); 57 | return slugMatch || nameMatch || authorMatch; 58 | } ); 59 | } ); 60 | 61 | const listItems = filteredItems.map( item => { 62 | const { name, slug, type } = item; 63 | 64 | return html` 65 |
  • 66 | <${ CheckboxControl } 67 | checked=${ packageExists( slug, type ) } 68 | onChange=${ checked => togglePackage( slug, type, checked ) } 69 | label=${ name } 70 | __nextHasNoMarginBottom 71 | /> 72 |
  • 73 | `; 74 | } ); 75 | 76 | return html`
    77 | <${ TextControl } 78 | label=${ __( 'Search', 'satispress' ) + ' ' + tab.title } 79 | hideLabelFromVision 80 | placeholder=${ __( 'Search', 'satispress' ) + ' ' + tab.title } 81 | onChange=${ setSearchTerm } 82 | value=${ searchTerm } 83 | __nextHasNoMarginBottom 84 | /> 85 | 86 |
    `; 87 | }; 88 | 89 | return html` 90 | <${ Panel }> 91 | <${ PanelHeader } label=${ __( 'Manage Packages', 'satispress' ) }> 92 | <${ Button } 93 | label=${ __( 'Close package inserter', 'satispress' ) } 94 | icon=${ closeSmallIcon } 95 | onClick=${ onClose } 96 | /> 97 | 98 | <${ TabPanel } tabs=${ tabs }> 99 | ${ tabContent } 100 | 101 | 102 | `; 103 | } 104 | 105 | export default PackageSelector; 106 | -------------------------------------------------------------------------------- /assets/js/components/package-table.js: -------------------------------------------------------------------------------- 1 | import { html, i18n } from '../utils/index.js'; 2 | import Releases from './releases.js'; 3 | 4 | const { __ } = i18n; 5 | 6 | function PackageTable( props ) { 7 | const { 8 | author, 9 | author_url, 10 | composer, 11 | description, 12 | name, 13 | homepage, 14 | releases, 15 | slug, 16 | type, 17 | } = props; 18 | 19 | return html` 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 47 | 48 | 49 | 50 | 51 | 52 | 53 |
    ${ composer.name }
    ${ description }
    ${ __( 'Name', 'satispress' ) }${ name }
    ${ __( 'Homepage', 'satispress' ) }${ homepage }
    ${ __( 'Authors', 'satispress' ) }${ author }
    ${ __( 'Releases', 'satispress' ) } 45 | <${ Releases } releases=${ releases } ...${ props } /> 46 |
    ${ __( 'Package Type', 'satispress' ) }${ composer.type }
    54 | `; 55 | }; 56 | 57 | export default PackageTable; 58 | -------------------------------------------------------------------------------- /assets/js/components/release-actions.js: -------------------------------------------------------------------------------- 1 | import { components, element, html, i18n } from '../utils/index.js'; 2 | 3 | const { Button, TextControl } = components; 4 | const { createElement, createInterpolateElement } = element; 5 | const { __, sprintf } = i18n; 6 | 7 | const selectField = ( e ) => e.nativeEvent.target.select(); 8 | 9 | function ReleaseActions( props ) { 10 | const { 11 | composerName, 12 | name, 13 | url, 14 | version, 15 | } = props; 16 | 17 | const requireValue = `"${ composerName }": "${ version }"`; 18 | const cliCommandValue = `composer require ${ composerName }:${ version }`; 19 | 20 | /* translators: %s: version number */ 21 | const buttonText = __( 'Download %s', 'satispress' ); 22 | 23 | const copyPasteHtml = createInterpolateElement( 24 | __( 'Copy and paste into composer.json', 'satispress' ), 25 | { code: createElement( 'code' ) } 26 | ); 27 | 28 | return html` 29 |
    30 | 31 | 32 | 33 | 36 | 44 | 45 | 46 | 49 | 60 | 61 | 62 | 65 | 73 | 74 | 75 | 83 | 84 | 85 |
    34 | 35 | 37 | <${ TextControl } 38 | value=${ url } 39 | readOnly="readonly" 40 | id="satispress-release-action-download-url-${ composerName }" 41 | onClick=${ selectField } 42 | /> 43 |
    47 | 48 | 50 | <${ TextControl } 51 | value=${ requireValue } 52 | readOnly="readonly" 53 | id="satispress-release-action-require-${ composerName }" 54 | onClick=${ selectField } 55 | /> 56 | 57 | ${ copyPasteHtml } 58 | 59 |
    63 | 64 | 66 | <${ TextControl } 67 | value=${ cliCommandValue } 68 | readOnly="readonly" 69 | id="satispress-release-action-cli-${ composerName }" 70 | onClick=${ selectField } 71 | /> 72 |
    76 | <${ Button } 77 | href=${ url } 78 | isPrimary 79 | > 80 | ${ sprintf( buttonText, version ) } 81 | 82 |
    86 |
    87 | `; 88 | } 89 | 90 | export default ReleaseActions; 91 | -------------------------------------------------------------------------------- /assets/js/components/releases.js: -------------------------------------------------------------------------------- 1 | import { components, element, html } from '../utils/index.js'; 2 | import ReleaseActions from './release-actions.js'; 3 | 4 | const { Button } = components; 5 | const { Fragment, useState } = element; 6 | 7 | const defaultRelease = { 8 | url: '', 9 | version: '', 10 | }; 11 | 12 | function Releases( props ) { 13 | const { 14 | author, 15 | author_url, 16 | composer, 17 | description, 18 | name, 19 | homepage, 20 | releases, 21 | type, 22 | } = props; 23 | 24 | const [ selectedRelease, setSelectedRelease ] = useState( defaultRelease ); 25 | 26 | const clearSelectedRelease = () => setSelectedRelease( defaultRelease ); 27 | 28 | const { version: selectedVersion } = selectedRelease; 29 | 30 | const releaseButtons = releases.map( ( release, index ) => { 31 | const isSelected = selectedVersion === release.version; 32 | 33 | let className = 'button satispress-release'; 34 | if ( isSelected ) { 35 | className += ' active'; 36 | } 37 | 38 | const onClick = () => { 39 | if ( selectedVersion === release.version ) { 40 | clearSelectedRelease(); 41 | } else { 42 | setSelectedRelease( release ); 43 | } 44 | }; 45 | 46 | return html` 47 | <${ Button } 48 | key=${ release.version } 49 | className=${ className } 50 | aria-expanded=${ isSelected } 51 | onClick=${ onClick } 52 | > 53 | ${ release.version } 54 | 55 | ${ ' ' } 56 | ` 57 | } ); 58 | 59 | const releaseActions = '' !== selectedVersion && html` 60 | <${ ReleaseActions } 61 | name=${ name } 62 | composerName=${ composer.name } 63 | ...${ selectedRelease } 64 | /> 65 | `; 66 | 67 | return html` 68 | <${ Fragment }> 69 | ${ releaseButtons } 70 | ${ releaseActions } 71 | 13 | <${ Button } 14 | isPrimary 15 | onClick=${ props.onButtonClick } 16 | > 17 | ${ __( 'Add Packages', 'satispress' ) } 18 | 19 | 20 | `; 21 | } 22 | 23 | function Repository( props ) { 24 | if ( ! props.packages.length ) { 25 | return html` 26 | <${ RepositoryPlaceholder } onButtonClick=${ props.onButtonClick } /> 27 | `; 28 | } 29 | 30 | return props.packages.map( ( item, index ) => 31 | html`<${ PackageTable } key=${ item.name } ...${ item } />` 32 | ); 33 | } 34 | 35 | export default Repository; 36 | -------------------------------------------------------------------------------- /assets/js/components/sidebar.js: -------------------------------------------------------------------------------- 1 | import { element, html } from '../utils/index.js'; 2 | 3 | const { createPortal } = element; 4 | 5 | // @todo https://developer.wordpress.org/block-editor/components/scroll-lock/ 6 | 7 | function Sidebar( props ) { 8 | return createPortal( 9 | props.children, 10 | props.root 11 | ); 12 | } 13 | 14 | export default Sidebar; 15 | -------------------------------------------------------------------------------- /assets/js/data/access.js: -------------------------------------------------------------------------------- 1 | import { apiFetch, data } from '../utils/index.js'; 2 | 3 | const { createReduxStore, register } = data; 4 | 5 | const STORE_KEY = 'satispress/access'; 6 | 7 | const DEFAULT_STATE = { 8 | apiKeys: [], 9 | userId: null, 10 | }; 11 | 12 | const createApiKey = ( name, userId ) => async ( { dispatch, select } ) => { 13 | const apiKeys = select.getApiKeys(); 14 | 15 | const result = await apiFetch( { 16 | path: '/satispress/v1/apikeys', 17 | method: 'POST', 18 | data: { 19 | name, 20 | user: userId, 21 | }, 22 | } ) 23 | 24 | dispatch.setApiKeys( [ 25 | ...apiKeys, 26 | result 27 | ] ); 28 | } 29 | 30 | const revokeApiKey = ( token, userId ) => async ( { dispatch, select } ) => { 31 | apiFetch( { 32 | path: `/satispress/v1/apikeys/${ token }?user=${ userId }`, 33 | method: 'DELETE', 34 | } ); 35 | 36 | const apiKeys = select.getApiKeys().filter( item => { 37 | return token !== item.token; 38 | } ); 39 | 40 | dispatch.setApiKeys( apiKeys ); 41 | } 42 | 43 | function setApiKeys( apiKeys ) { 44 | return { 45 | type: 'SET_API_KEYS', 46 | apiKeys: apiKeys, 47 | }; 48 | } 49 | 50 | function setUserId( userId ) { 51 | return { 52 | type: 'SET_USER_ID', 53 | userId: userId, 54 | }; 55 | } 56 | 57 | const store = createReduxStore( STORE_KEY, { 58 | reducer( state = DEFAULT_STATE, action ) { 59 | switch ( action.type ) { 60 | case 'SET_API_KEYS' : 61 | return { 62 | ...state, 63 | apiKeys: action.apiKeys, 64 | }; 65 | 66 | case 'SET_USER_ID' : 67 | return { 68 | ...state, 69 | userId: action.userId, 70 | }; 71 | } 72 | 73 | return state; 74 | }, 75 | actions: { 76 | createApiKey, 77 | revokeApiKey, 78 | setApiKeys, 79 | setUserId, 80 | }, 81 | selectors: { 82 | getApiKeys( state ) { 83 | return state.apiKeys || []; 84 | }, 85 | getUserId( state ) { 86 | return state.userId || null; 87 | }, 88 | }, 89 | resolvers: { 90 | getApiKeys: () => async ( { dispatch, select } ) => { 91 | const userId = select.getUserId(); 92 | const apiKeys = await apiFetch( { path: `/satispress/v1/apikeys?user=${ userId }` } ); 93 | dispatch.setApiKeys( apiKeys ); 94 | }, 95 | }, 96 | } ); 97 | 98 | register( store ); 99 | -------------------------------------------------------------------------------- /assets/js/data/packages.js: -------------------------------------------------------------------------------- 1 | import { apiFetch, data } from '../utils/index.js'; 2 | 3 | const { createReduxStore, register } = data; 4 | 5 | const STORE_KEY = 'satispress/packages'; 6 | 7 | const DEFAULT_STATE = { 8 | packages: [], 9 | plugins: [], 10 | themes: [], 11 | }; 12 | 13 | const packageExists = ( packages, slug, type ) => { 14 | return !! packages.filter( item => slug === item.slug && type === item.type ).length; 15 | } 16 | 17 | const compareByName = ( a, b ) => { 18 | if ( a.name < b.name ) { 19 | return -1; 20 | } 21 | 22 | if ( a.name > b.name ) { 23 | return 1; 24 | } 25 | 26 | return 0; 27 | }; 28 | 29 | const addPackage = ( slug, type ) => async ( { dispatch, select } ) => { 30 | const packages = select.getPackages(); 31 | 32 | if ( packageExists( packages, slug, type ) ) { 33 | return; 34 | } 35 | 36 | const result = await apiFetch( { 37 | path: '/satispress/v1/packages', 38 | method: 'POST', 39 | data: { 40 | slug, 41 | type, 42 | }, 43 | } ); 44 | 45 | dispatch.setPackages( 46 | [ 47 | ...packages, 48 | result 49 | ] 50 | ); 51 | } 52 | 53 | const removePackage = ( slug, type ) => async ( { dispatch, select } ) => { 54 | const packages = select.getPackages(); 55 | 56 | await apiFetch( { 57 | path: `/satispress/v1/packages/${ slug }?type=${ type }`, 58 | method: 'DELETE', 59 | } ); 60 | 61 | dispatch.setPackages( 62 | packages.filter( item => { 63 | return slug !== item.slug || type !== item.type; 64 | } ) 65 | ); 66 | } 67 | 68 | function setPackages( packages ) { 69 | return { 70 | type: 'SET_PACKAGES', 71 | packages: packages.sort( compareByName ) 72 | }; 73 | } 74 | 75 | function setPlugins( plugins ) { 76 | return { 77 | type: 'SET_PLUGINS', 78 | plugins: plugins.sort( compareByName ) 79 | }; 80 | } 81 | 82 | function setThemes( themes ) { 83 | return { 84 | type: 'SET_THEMES', 85 | themes: themes.sort( compareByName ) 86 | }; 87 | } 88 | 89 | const getPackages = () => async ( { dispatch, select } ) => { 90 | const packages = await apiFetch( { path: '/satispress/v1/packages' } ); 91 | dispatch.setPackages( packages ); 92 | } 93 | 94 | const getPlugins = () => async ( { dispatch, select } ) => { 95 | const plugins = await apiFetch( { path: '/satispress/v1/plugins?_fields=slug,name,type' } ); 96 | dispatch.setPlugins( plugins ); 97 | } 98 | 99 | const getThemes = () => async ( { dispatch, select } ) => { 100 | const themes = await apiFetch( { path: '/satispress/v1/themes?_fields=slug,name,type' } ); 101 | dispatch.setThemes( themes ); 102 | } 103 | 104 | const store = createReduxStore( STORE_KEY, { 105 | reducer( state = DEFAULT_STATE, action ) { 106 | switch ( action.type ) { 107 | case 'SET_PACKAGES' : 108 | return { 109 | ...state, 110 | packages: action.packages, 111 | }; 112 | 113 | case 'SET_PLUGINS' : 114 | return { 115 | ...state, 116 | plugins: action.plugins, 117 | }; 118 | 119 | case 'SET_THEMES' : 120 | return { 121 | ...state, 122 | themes: action.themes, 123 | }; 124 | } 125 | 126 | return state; 127 | }, 128 | actions: { 129 | addPackage, 130 | removePackage, 131 | setPackages, 132 | setPlugins, 133 | setThemes, 134 | }, 135 | selectors: { 136 | getPackages( state ) { 137 | return state.packages || []; 138 | }, 139 | getPlugins( state ) { 140 | return state.plugins || []; 141 | }, 142 | getThemes( state ) { 143 | return state.themes || []; 144 | }, 145 | }, 146 | resolvers: { 147 | getPackages, 148 | getPlugins, 149 | getThemes, 150 | } 151 | } ); 152 | 153 | register( store ); 154 | -------------------------------------------------------------------------------- /assets/js/repository.js: -------------------------------------------------------------------------------- 1 | import { components, data, element, html, i18n } from './utils/index.js'; 2 | import Repository from './components/repository.js'; 3 | import PackageSelector from './components/package-selector.js'; 4 | import Sidebar from './components/sidebar.js'; 5 | import { closeSmallIcon } from './components/icons.js'; 6 | import './data/packages.js'; 7 | 8 | const { Button } = components; 9 | const { dispatch, useSelect } = data; 10 | const { createRoot, Fragment, useEffect, useState } = element; 11 | const { __ } = i18n; 12 | 13 | const { addPackage, removePackage } = dispatch( 'satispress/packages' ); 14 | 15 | const bodyEl = document.body; 16 | const sidebarEl = document.getElementById( 'satispress-screen-sidebar' ); 17 | 18 | function App() { 19 | const packages = useSelect( select => select( 'satispress/packages' ).getPackages() ); 20 | 21 | const [ isSidebarOpen, setSidebarStatus ] = useState( false ); 22 | const closeSidebar = () => setSidebarStatus( false ); 23 | const openSidebar = () => setSidebarStatus( true ); 24 | const toggleSidebar = () => setSidebarStatus( ! isSidebarOpen ); 25 | 26 | useEffect( () => { 27 | window.addEventListener( 'hashchange', closeSidebar ); 28 | bodyEl.classList.toggle( 'sidebar-is-open', isSidebarOpen ); 29 | 30 | return () => { 31 | window.removeEventListener( 'hashchange', closeSidebar ); 32 | }; 33 | } ); 34 | 35 | return html` 36 | <${ Fragment }> 37 | ${ !! packages.length && html` 38 | <${ Button } 39 | isSecondary 40 | isPressed=${ isSidebarOpen } 41 | icon=${ isSidebarOpen && closeSmallIcon } 42 | onClick=${ toggleSidebar } 43 | > 44 | ${ __( 'Manage Packages', 'satispress' ) } 45 | ` 46 | } 47 | <${ Repository } 48 | packages=${ packages } 49 | onButtonClick=${ openSidebar } 50 | /> 51 | ${ isSidebarOpen && html` 52 | <${ Sidebar } root=${ sidebarEl }> 53 | <${ PackageSelector } 54 | packages=${ packages } 55 | onAddPackage=${ addPackage } 56 | onRemovePackage=${ removePackage } 57 | onClose=${ closeSidebar } 58 | /> 59 | ` 60 | } 61 | 62 | `; 63 | } 64 | 65 | const root = createRoot( document.getElementById( 'satispress-repository' ) ); 66 | root.render( html`<${ App } />` ); 67 | -------------------------------------------------------------------------------- /assets/js/utils/index.js: -------------------------------------------------------------------------------- 1 | import htm from '../vendor/htm.module.js'; 2 | 3 | export const { apiFetch, components, data, element, i18n } = wp; 4 | 5 | export const html = htm.bind( React.createElement ); 6 | 7 | export const noop = () => {}; 8 | -------------------------------------------------------------------------------- /assets/js/vendor/htm.module.js: -------------------------------------------------------------------------------- 1 | var n = function (t, s, r, e) {var u;s[0] = 0;for (var h = 1; h < s.length; h++) {var p = s[h++],a = s[h] ? (s[0] |= p ? 1 : 2, r[s[h++]]) : s[++h];3 === p ? e[0] = a : 4 === p ? e[1] = Object.assign(e[1] || {}, a) : 5 === p ? (e[1] = e[1] || {})[s[++h]] = a : 6 === p ? e[1][s[++h]] += a + "" : p ? (u = t.apply(a, n(t, a, r, ["", null])), e.push(u), a[0] ? s[0] |= 2 : (s[h - 2] = 0, s[h] = u)) : e.push(a);}return e;},t = new Map();export default function (s) {var r = t.get(this);return r || (r = new Map(), t.set(this, r)), (r = n(this, r.get(s) || (r.set(s, r = function (n) {for (var t, s, r = 1, e = "", u = "", h = [0], p = function (n) {1 === r && (n || (e = e.replace(/^\s*\n\s*|\s*\n\s*$/g, ""))) ? h.push(0, n, e) : 3 === r && (n || e) ? (h.push(3, n, e), r = 2) : 2 === r && "..." === e && n ? h.push(4, n, 0) : 2 === r && e && !n ? h.push(5, 0, !0, e) : r >= 5 && ((e || !n && 5 === r) && (h.push(r, 0, e, s), r = 6), n && (h.push(r, n, 0, s), r = 6)), e = "";}, a = 0; a < n.length; a++) {a && (1 === r && p(), p(a));for (var l = 0; l < n[a].length; l++) t = n[a][l], 1 === r ? "<" === t ? (p(), h = [h], r = 3) : e += t : 4 === r ? "--" === e && ">" === t ? (r = 1, e = "") : e = t + e[0] : u ? t === u ? u = "" : e += t : '"' === t || "'" === t ? u = t : ">" === t ? (p(), r = 1) : r && ("=" === t ? (r = 5, s = e, e = "") : "/" === t && (r < 5 || ">" === n[a][l + 1]) ? (p(), 3 === r && (h = h[0]), r = h, (h = h[0]).push(2, 0, r), r = 0) : " " === t || "\t" === t || "\n" === t || "\r" === t ? (p(), r = 2) : e += t), 3 === r && "!--" === e && (r = 4, h = h[0]);}return p(), h;}(s)), r), arguments, [])).length > 1 ? r : r[0];} -------------------------------------------------------------------------------- /bin/archive: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const archiver = require( 'archiver' ); 4 | const config = require( '../package.json' ); 5 | const fs = require( 'fs-extra' ); 6 | const glob = require( 'glob' ); 7 | const path = require( 'path' ); 8 | const spawnProcess = require( 'child_process' ).spawn; 9 | 10 | const argv = require( 'minimist' )( process.argv.slice( 2 ), { 11 | string: [ 'version' ] 12 | } ); 13 | 14 | const pluginFile = path.join( __dirname, `../${ config.name }.php` ); 15 | const packageJson = path.join( __dirname, '../package.json' ); 16 | let version = argv.version; 17 | 18 | if ( ! version ) { 19 | const contents = fs.readFileSync( pluginFile, 'utf8' ); 20 | version = contents.match( /Version:[\s]+(.+)/ )[1]; 21 | } 22 | 23 | replaceInFile( pluginFile, /(Version:[\s]+).+/, `\$1${ version }` ) 24 | .then( () => replaceInFile( pluginFile, /VERSION = '.+'/, `VERSION = '${ version }'` ) ) 25 | .then( () => replaceInFile( packageJson, /"version": "[^"]+"/, `"version": "${ version }"` )) 26 | .then( () => spawn( 'composer', [ 'prepare-for-release' ] ) ) 27 | .then( () => compress( config.name, version, config.distFiles ) ); 28 | 29 | function compress( slug, version, files ) { 30 | return new Promise( ( resolve, reject ) => { 31 | const dist = path.join( __dirname, '../dist' ); 32 | 33 | try { 34 | fs.mkdirSync( dist ); 35 | } catch ( error ) {} 36 | 37 | const archive = archiver.create( 'zip' ); 38 | const output = fs.createWriteStream( path.join( dist, `${ slug }-${ version }.zip` ) ); 39 | 40 | output.on( 'close', () => { 41 | console.log( `Created dist/${ slug }-${ version }.zip` ); 42 | console.log( `Total bytes: ${ archive.pointer() }` ); 43 | resolve(); 44 | } ); 45 | 46 | output.on( 'error', ( error ) => reject( error ) ); 47 | 48 | archive.pipe( output ); 49 | 50 | files.forEach( pattern => { 51 | glob.sync( pattern, { 52 | nodir: true 53 | } ).forEach( file => { 54 | archive.file( file, { name: `${ slug }/${ file }` } ) 55 | } ); 56 | } ); 57 | 58 | archive.finalize(); 59 | } ); 60 | } 61 | 62 | function replaceInFile( file, pattern, replace ) { 63 | return new Promise( ( resolve, reject ) => { 64 | let contents = fs.readFileSync( file, 'utf8' ); 65 | contents = contents.replace( pattern, replace ); 66 | fs.writeFileSync( file, contents ); 67 | resolve(); 68 | } ); 69 | } 70 | 71 | function spawn( file, args ) { 72 | return new Promise( ( resolve, reject ) => { 73 | const child = spawnProcess( file, args, { stdio: 'inherit' } ); 74 | child.on( 'error', reject ); 75 | child.on( 'close', resolve ); 76 | } ); 77 | } 78 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cedaro/satispress", 3 | "description": "Generate a Composer repository from installed WordPress plugins and themes.", 4 | "keywords": [ 5 | "wordpress", 6 | "composer", 7 | "satis" 8 | ], 9 | "type": "wordpress-plugin", 10 | "homepage": "https://github.com/cedaro/satispress", 11 | "license": "GPL-2.0-or-later", 12 | "authors": [ 13 | { 14 | "name": "Brady Vercher", 15 | "email": "brady@blazersix.com", 16 | "homepage": "https://www.cedaro.com/" 17 | }, 18 | { 19 | "name": "Gary Jones", 20 | "homepage": "https://gamajo.com", 21 | "role": "Developer" 22 | } 23 | ], 24 | "support": { 25 | "issues": "https://github.com/cedaro/satispress/issues", 26 | "source": "https://github.com/cedaro/satispress" 27 | }, 28 | "config": { 29 | "allow-plugins": { 30 | "dealerdirect/phpcodesniffer-composer-installer": true, 31 | "composer/installers": true 32 | }, 33 | "preferred-install": "dist", 34 | "sort-packages": true 35 | }, 36 | "prefer-stable": true, 37 | "repositories": { 38 | "wordpress": { 39 | "type": "vcs", 40 | "url": "https://github.com/WordPress/wordpress-develop", 41 | "no-api": true 42 | }, 43 | "wp-test-suite": { 44 | "type": "vcs", 45 | "url": "https://github.com/cedaro/wp-test-suite" 46 | } 47 | }, 48 | "require": { 49 | "php": ">=8.0", 50 | "cedaro/wp-plugin": "^0.5", 51 | "composer/installers": "^2.0", 52 | "composer/semver": "^3.2", 53 | "pimple/pimple": "^3.5", 54 | "psr/container": "^2.0", 55 | "psr/log": "^2.0" 56 | }, 57 | "require-dev": { 58 | "brain/monkey": "^2.2", 59 | "cedaro/wp-test-suite": "dev-develop", 60 | "dealerdirect/phpcodesniffer-composer-installer": "^1.0", 61 | "phpcompatibility/phpcompatibility-wp": "^2", 62 | "phpunit/phpunit": "^8", 63 | "roave/security-advisories": "dev-latest", 64 | "squizlabs/php_codesniffer": "^3.3", 65 | "wordpress/wordpress": "^6.6", 66 | "wp-cli/i18n-command": "dev-main", 67 | "wp-coding-standards/wpcs": "^3" 68 | }, 69 | "autoload": { 70 | "psr-4": { 71 | "SatisPress\\": "src/" 72 | }, 73 | "files": [ 74 | "src/functions.php" 75 | ] 76 | }, 77 | "autoload-dev": { 78 | "psr-4": { 79 | "SatisPress\\Test\\": "tests/phpunit/" 80 | } 81 | }, 82 | "scripts": { 83 | "install-codestandards": [ 84 | "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin::run" 85 | ], 86 | "makepot": "./vendor/bin/wp i18n make-pot . languages/satispress.pot", 87 | "phpcs": "./vendor/bin/phpcs", 88 | "prepare-for-release": [ 89 | "composer install", 90 | "composer makepot", 91 | "composer install --no-dev --prefer-dist", 92 | "composer dump-autoload --no-dev --optimize" 93 | ], 94 | "test": "./vendor/bin/phpunit --testsuite=Unit --colors=always" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /docs/alternatives.md: -------------------------------------------------------------------------------- 1 | # Alternatives 2 | 3 | ## Packagist 4 | 5 | [Packagist](https://packagist.org/) is the main Composer repository. It aggregates PHP packages from public sources like GitHub that can be installed with Composer. 6 | 7 | ## WordPress Packagist 8 | 9 | [WordPress Packagist](https://wpackagist.org/) mirrors the WordPress plugin and theme directories as a Composer repository so they can be installed with Composer. 10 | 11 | ## Private Packagist 12 | 13 | [Private Packagist](https://packagist.com/) is a commercial package hosting product offering professional support and web based management of private and public packages, and granular access permissions. 14 | 15 | Some of Private Packagist's revenue is used to pay for Composer and Packagist.org development and hosting so using it is a good way to support the maintenance of these open source projects financially. 16 | 17 | ## Satis 18 | 19 | [Satis](https://getcomposer.org/doc/articles/handling-private-packages-with-satis.md#satis) is an open-source, static Composer repository generator. It is a bit like an ultra-lightweight, static file-based version of Packagist and can be used to host the metadata of your company's private packages, or your own. 20 | 21 | ## Release Belt 22 | 23 | [Release Belt](https://github.com/Rarst/release-belt) is a Composer repository, which serves to quickly integrate third party non–Composer releases into Composer workflow. 24 | 25 | [Back to Index](index.md) 26 | -------------------------------------------------------------------------------- /docs/composer.md: -------------------------------------------------------------------------------- 1 | # Using Composer with SatisPress 2 | 3 | Once SatisPress is installed and configured you can include the SatisPress repository in the list of repositories in your `composer.json` or `satis.json`, then require the packages using `satispress` (or your custom setting) as the vendor: 4 | 5 | ```json 6 | { 7 | "repositories": { 8 | "satispress": { 9 | "type": "composer", 10 | "url": "https://packages.example.com/satispress/" 11 | } 12 | }, 13 | "require": { 14 | "composer/installers": "^1.0", 15 | "satispress/atomic-blocks": "*", 16 | "satispress/genesis": "*", 17 | "satispress/gravityforms": "*" 18 | } 19 | } 20 | ``` 21 | 22 | The repository can also be added by running the `config` command: 23 | 24 | ```sh 25 | composer config repositories.satispress composer https://packages.example.com/satispress/ 26 | ``` 27 | 28 | _The `satispress` vendor name can be changed on the [Settings page](settings.md)._ 29 | 30 | ## Installing Packages 31 | 32 | When you install a package from a SatisPress repository for the first time, Composer will notify you that authentication is required. Use your API Key for the username and `satispress` as the password. Composer will then ask if you want to store the credentials, which should be fine. 33 | 34 | ```sh 35 | $ ls -1 36 | composer.json 37 | 38 | $ composer install 39 | Loading composer repositories with package information 40 | 41 | Authentication required (packages.example.com): 42 | Username: aUEZYqq6pXlMjdg8swe0rQgMCZAPJNaR 43 | Password: 44 | Do you want to store credentials for local.test in /Users/bradyvercher/.composer/auth.json ? [Yn] y 45 | Updating dependencies (including require-dev) 46 | Package operations: 4 installs, 0 updates, 0 removals 47 | - Installing composer/installers (v1.5.0): 48 | - Installing satispress/genesis (2.6.1): 49 | - Installing satispress/gravityforms (2.3.2): 50 | - Installing satispress/atomic-blocks (1.2.1): 51 | Writing lock file 52 | Generating autoload files 53 | 54 | $ ls -1 55 | composer.json 56 | composer.lock 57 | vendor 58 | wp-content 59 | 60 | $ ls -1 wp-content/plugins 61 | gravityforms 62 | ``` 63 | 64 | ## Configuring Authentication 65 | 66 | It's also possible to configure Composer to use your API Key by running the `config` command: 67 | 68 | ```sh 69 | $ composer config http-basic.packages.example.com \ 70 | aUEZYqq6pXlMjdg8swe0rQgMCZAPJNaR satispress 71 | ``` 72 | 73 | After running that command, you should end up with an `auth.json` in your project alongside the `composer.json` that looks like this: 74 | 75 | ```json 76 | { 77 | "http-basic": { 78 | "packages.example.com": { 79 | "username": "aUEZYqq6pXlMjdg8swe0rQgMCZAPJNaR", 80 | "password": "satispress" 81 | } 82 | } 83 | } 84 | ``` 85 | 86 | The [Composer documentation explains the benefit](https://getcomposer.org/doc/articles/http-basic-authentication.md) of using a local `auth.json`: 87 | 88 | > The main advantage of the `auth.json` file is that it can be gitignored so that every developer in your team can place their own credentials in there, which makes revocation of credentials much easier than if you all share the same. 89 | 90 | [Back to Index](index.md) 91 | -------------------------------------------------------------------------------- /docs/images/api-keys.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedaro/satispress/7b83e5ba4766906193bb5bc72fd201f13b5d228e/docs/images/api-keys.png -------------------------------------------------------------------------------- /docs/images/repository-setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedaro/satispress/7b83e5ba4766906193bb5bc72fd201f13b5d228e/docs/images/repository-setup.png -------------------------------------------------------------------------------- /docs/images/repository.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedaro/satispress/7b83e5ba4766906193bb5bc72fd201f13b5d228e/docs/images/repository.png -------------------------------------------------------------------------------- /docs/images/revoke-api-keys.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedaro/satispress/7b83e5ba4766906193bb5bc72fd201f13b5d228e/docs/images/revoke-api-keys.png -------------------------------------------------------------------------------- /docs/images/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedaro/satispress/7b83e5ba4766906193bb5bc72fd201f13b5d228e/docs/images/settings.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # SatisPress Documentation 2 | 3 | Generate a Composer repository from installed WordPress plugins and themes. 4 | 5 | ## Why a WordPress Installation? 6 | 7 | Many plugins and themes don't have public repositories, making managing them with Composer a hassle. SatisPress allows you to manage them in a standard WordPress installation, leveraging core's built-in update process to handle the myriad licensing schemes that would be impossible to account for outside of WordPress. 8 | 9 | Packages are exposed via a `packages.json` file for inclusion as a Composer repository in a project's `composer.json` or even your own `satis.json`. 10 | 11 | ## Table of Contents 12 | 13 | 1. [Installation](installation.md) 14 | 1. Managing SatisPress 15 | 1. [Getting Started](setup.md) 16 | 1. [Security](security.md) 17 | 1. [Settings](settings.md) 18 | 1. [Using Composer](composer.md) 19 | 1. Workflows 20 | 1. [Running SatisPress in Production](workflows/production.md) 21 | 1. [Central Package Server](workflows/central-server.md) 22 | 1. Continous Integration 23 | 1. Commercial Vendors 24 | 1. [MU Plugins](mu-plugins.md) 25 | 1. [Logging](logging.md) 26 | 1. [Integrations](integrations.md) 27 | 1. [Troubleshooting](troubleshooting.md) 28 | 1. [Alternatives](alternatives.md) 29 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | As a standard WordPress plugin, installation is the same as most other plugins. This can be done by uploading the plugin as a zip file, or using Composer. 4 | 5 | SatisPress requires PHP 7.0 or later. 6 | 7 | ## Zip File 8 | 9 | 1. Download the [latest release](https://github.com/cedaro/satispress/releases/latest) from GitHub (use the asset named `satispress-{version}.zip`). 10 | 2. Go to the _Plugins → Add New_ screen in your WordPress admin panel and click the __Upload Plugin__ button at the top. 11 | 3. Upload the zipped archive. 12 | 4. Click the __Activate Plugin__ link after installation completes. 13 | 14 | ### Updates 15 | 16 | [GitHub Updater](https://github.com/afragen/github-updater) can be used to receive notifications and install updates when new releases are available. 17 | 18 | *__Note__: If you're using GitHub Updater to install SatisPress, copy the full URL to the [latest release asset](https://github.com/cedaro/satispress/releases/latest) (the asset named `satispress-{version}.zip`). Using the repository URL alone won't work.* 19 | 20 | ## Composer 21 | 22 | SatisPress is available on [Packagist](https://packagist.org/packages/cedaro/satispress), so it can be installed via Composer: 23 | 24 | ```bash 25 | composer require cedaro/satispress 26 | ``` 27 | 28 | [Back to Index](index.md) 29 | -------------------------------------------------------------------------------- /docs/integrations.md: -------------------------------------------------------------------------------- 1 | # Integrations 2 | 3 | ## Envato Market 4 | 5 | The [Envato Market](https://envato.com/market-plugin/) plugin allows for updating themes and plugins purchased from Envato. It uses a custom process for updating packages. SatisPress includes an adapter to cache updates as they become available. 6 | 7 | ## Members 8 | 9 | SatisPress registers capabilities and a capability group with the [Members](https://wordpress.org/plugins/members/) plugin. 10 | 11 | [Back to Index](index.md) 12 | -------------------------------------------------------------------------------- /docs/logging.md: -------------------------------------------------------------------------------- 1 | # Logging 2 | 3 | SatisPress implements a [PSR-3 Logger Interface](https://www.php-fig.org/psr/psr-3/) for logging messages when `WP_DEBUG` is enabled. The default implementation only logs messages with a [log level](https://www.php-fig.org/psr/psr-3/#5-psrlogloglevel) of `warning` or higher. 4 | 5 | Messages are logged via PHP's `error_log()` function, which typically saves them to the `wp-content/debug.log` file when `WP_DEBUG` is enabled. 6 | 7 | ## Changing the Log Level 8 | 9 | To log more or less information, the log level can be adjusted in the DI container. 10 | 11 | ```php 12 | pushHandler( new ErrorLogHandler( ErrorLogHandler::OPERATING_SYSTEM, LOGGER::WARNING ) ); 37 | $logger->pushProcessor( new PsrLogMessageProcessor ); 38 | 39 | return $logger; 40 | }; 41 | }, 10, 2 ); 42 | ``` 43 | 44 | _Monolog should be required with Composer and the autoloader needs to be included before using it in your project._ 45 | 46 | 47 | [Back to Index](index.md) 48 | -------------------------------------------------------------------------------- /docs/mu-plugins.md: -------------------------------------------------------------------------------- 1 | # Must-use (MU) Plugins 2 | 3 | SatisPress doesn't provide support for mu-plugins since they usually require manual installation and aren't managed by the WordPress update process. 4 | 5 | However, if you can install an mu-plugin as a regular plugin in your SatisPress instance, you can [force Composer to install it as an mu-plugin](https://getcomposer.org/doc/faqs/how-do-i-install-a-package-to-a-custom-path-for-my-framework.md) in your project. 6 | 7 | As an example, the following configuration in `composer.json` would install SatisPress as an mu-plugin: 8 | 9 | 10 | ```json 11 | { 12 | "extra": { 13 | "installer-paths": { 14 | "wp-content/mu-plugins/{$name}/": [ 15 | "type:wordpress-muplugin", 16 | "cedaro/satispress" 17 | ] 18 | } 19 | } 20 | } 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/settings.md: -------------------------------------------------------------------------------- 1 | # Settings 2 | 3 | At _Settings → SatisPress → Settings_, you'll find the settings page: 4 | 5 | ![Screenshot of the SatisPress Settings page](images/settings.png) 6 | 7 | ## Vendor 8 | 9 | When requiring a package from SatisPress, the default would be a package name like `satispress/genesis`. 10 | 11 | The **Vendor** field allows this to be changed; a value of `mypremiumcode` would mean the `require` package name would be `mypremiumcode/genesis`. 12 | 13 | Once you've started using a vendor name in your projects' `composer.json` manifests, it's a good idea to leave this setting alone. Otherwise you'll need to update every reference to the old vendor name and you may not be able to install dependencies if you need to check out an older version of a project. 14 | 15 | [Back to Index](index.md) 16 | -------------------------------------------------------------------------------- /docs/setup.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | To get started using SatisPress, a few things need to be done: 4 | 5 | 1. Add plugins or themes to the repository 6 | 2. Create an API Key to access the repository 7 | 3. Configure Composer to connect to the repository 8 | 9 | ## Add Plugins or Themes to the Repository 10 | 11 | When plugins and themes are added to the SatisPress repository the version that is currently installed is cached and saved as an artifact (zip file). New releases are also downloaded and saved as soon as WordPress is aware they're available. 12 | 13 | All cached versions are exposed in `packages.json` so they can be required with Composer -- even versions that haven't yet been installed by WordPress! 14 | 15 | When you first visit the *Settings → SatisPress* screen, click the __Add Packages__ button to open the sidebar to select the plugins and themes you want to include in your repository. 16 | 17 | ![Screenshot of the Repository tab showing how to add packages](images/repository-setup.png) 18 | 19 | Once packages have been added to the repository and releases become available, they can be downloaded directly from the **Repository** tab by clicking the button with the corresponding version number. 20 | 21 | ![Screenshot of the Repository tab showing releases for a package](images/repository.png) 22 | 23 | ## Create an API Key 24 | 25 | Cached packages are automatically protected, so you'll need to create an API key on the **Access** tab to access them from Composer. 26 | 27 | See the documentation on [Security](security.md) for more information. 28 | 29 | ![Screenshot of the Access tab showing a list of API keys](images/api-keys.png) 30 | 31 | ## Configure Composer 32 | 33 | Finally, you need to add the SatisPress repository to `composer.json` and connect to it using an API key. The **Composer** tab contains instructions for configuring Composer. 34 | 35 | See the documentation on [Composer](composer.md) for more information. 36 | 37 | [Back to Index](index.md) 38 | -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | ## Basic Auth not working 4 | 5 | Certain Apache configurations strip `Authorization` headers and don't allow the Basic Auth credentials to be made available to PHP. To get around this, you'll need to set an environment variable in the site's root `.htaccess` file: 6 | 7 | ``` 8 | 9 | RewriteEngine On 10 | RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 11 | 12 | ``` 13 | 14 | *This __must__ be in the site root `.htaccess` (not the `wp-content/uploads/satispress-xxxx/.htaccess`), and it __must__ come before the WordPress rewrites.* 15 | 16 | ## Rewrite Rules 17 | 18 | Some servers don't route URLs ending with a file extension through WordPress' front controller, so it's possible that some rewrite rules SatisPress registers won't work. This primarily affects the route that delivers the `packages.json` file for Composer since it ends in `.json`. You will typically see a 404 error document generated by the server rather than a 404 page with your theme's design when this happens. 19 | 20 | Try visiting the _Settings → Permalinks_ page and flushing the site's permalinks first if the URL for `packages.json` doesn't work. _[Rewrite Rules Inspector](https://wordpress.org/plugins/rewrite-rules-inspector/) is a handy plugin for viewing or flushing rewrite rules._ 21 | 22 | If that still doesn't work, you may need to add a rewrite rule to your server: 23 | 24 | ### Apache 25 | 26 | An external rewrite rule should be automatically added to `.htaccess` to handle `packages.json`. 27 | 28 | ### nginx 29 | 30 | `rewrite ^/satispress/packages.json$ /index.php last;` 31 | 32 | [Back to Index](index.md) 33 | -------------------------------------------------------------------------------- /docs/workflows/central-server.md: -------------------------------------------------------------------------------- 1 | # Central Package Server 2 | 3 | Agencies, development teams, or individuals that manage multiple websites may want to run a central package server to reduce some maintenance overhead. It can also make sense if the production site(s) is locked down for security or privacy reasons and doesn't check for updates. 4 | 5 | To run a central package server, WordPress would be installed on a separate domain or subdomain and be dedicated to package management. 6 | 7 | ## Benefits 8 | 9 | * Production can be locked down for increased security and privacy 10 | * Developers and CI/CD servers won't generate extra load on the production site 11 | * Packages can be shared across many different sites 12 | 13 | ## Concerns 14 | 15 | * Licenses must be activated on the central repository rather than production sites, so an additional license may be required 16 | * Performance of the package server may become slow depending on the number of packages managed 17 | * Additional infrastructure that needs to be managed 18 | 19 | [Back to Index](../index.md) 20 | -------------------------------------------------------------------------------- /docs/workflows/commercial-vendors.md: -------------------------------------------------------------------------------- 1 | # Commercial Vendors 2 | 3 | [Back to Index](../index.md) 4 | -------------------------------------------------------------------------------- /docs/workflows/continuous-integration.md: -------------------------------------------------------------------------------- 1 | # Continuous Integrations 2 | 3 | [Back to Index](../index.md) 4 | -------------------------------------------------------------------------------- /docs/workflows/production.md: -------------------------------------------------------------------------------- 1 | # Running SatisPress in Production 2 | 3 | When managing a single site or keeping each project self-contained is the end goal, it's possible to set up the production site as the package server. 4 | 5 | ## Benefits 6 | 7 | * No additional infrastructure needs to be managed 8 | * Each site is self-contained 9 | 10 | ## Concerns 11 | 12 | * Developers and CI/CD servers may generate additional load on the live site 13 | * Packages are cached on the live site, so the may need to be excluded from backups and pruned now and then 14 | * Deployments need to be atomic. A CI/CD server should be able to download all packages before a new version is deployed. Ideally uploads would not be stored within the deployment path. 15 | 16 | [Back to Index](../index.md) 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "satispress", 3 | "version": "2.0.0", 4 | "description": "Generate a Composer repository from installed WordPress plugins and themes.", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/cedaro/satispress.git" 8 | }, 9 | "author": { 10 | "name": "Brady Vercher", 11 | "email": "brady@blazersix.com", 12 | "url": "https://www.cedaro.com/" 13 | }, 14 | "license": "GPL-2.0+", 15 | "bugs": { 16 | "url": "https://github.com/cedaro/satispress/issues" 17 | }, 18 | "homepage": "https://github.com/cedaro/satispress", 19 | "devDependencies": { 20 | "archiver": "^5.2.0", 21 | "fs-extra": "^9.0.1", 22 | "glob": "^7.1.6", 23 | "minimist": "^1.2.5" 24 | }, 25 | "distFiles": [ 26 | "satispress.php", 27 | "README.md", 28 | "{assets,languages,src,views}/**", 29 | "vendor/autoload.php", 30 | "vendor/composer/*.php", 31 | "vendor/{cedaro/wp-plugin,composer/semver,psr/container,psr/log}/src/**", 32 | "vendor/pimple/pimple/src/Pimple/!(Tests){*.php,**/*.php}" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | ./tests/phpunit/Unit 13 | 14 | 15 | ./tests/phpunit/Integration 16 | 17 | 18 | 19 | 20 | 21 | src 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /satispress.php: -------------------------------------------------------------------------------- 1 | 7 | * @license GPL-2.0-or-later 8 | * 9 | * @wordpress-plugin 10 | * Plugin Name: SatisPress 11 | * Plugin URI: https://github.com/cedaro/satispress 12 | * Description: Generate a Composer repository from installed WordPress plugins and themes. 13 | * Version: 2.0.0 14 | * Author: Cedaro 15 | * Author URI: https://www.cedaro.com/ 16 | * License: GPL-2.0-or-later 17 | * License URI: https://www.gnu.org/licenses/gpl-2.0.html 18 | * Text Domain: satispress 19 | * Domain Path: /languages 20 | * Requires PHP: 8.0 21 | * Network: true 22 | * GitHub Plugin URI: cedaro/satispress 23 | * Release Asset: true 24 | */ 25 | 26 | declare ( strict_types = 1 ); 27 | 28 | namespace SatisPress; 29 | 30 | // Exit if accessed directly. 31 | if ( ! \defined( 'ABSPATH' ) ) { 32 | exit; 33 | } 34 | 35 | /** 36 | * Plugin version. 37 | * 38 | * @var string 39 | */ 40 | const VERSION = '2.0.0'; 41 | 42 | // Load the Composer autoloader. 43 | if ( file_exists( __DIR__ . '/vendor/autoload.php' ) ) { 44 | require __DIR__ . '/vendor/autoload.php'; 45 | } 46 | 47 | // Display a notice and bail if dependencies are missing. 48 | if ( ! function_exists( __NAMESPACE__ . '\autoloader_classmap' ) ) { 49 | require_once __DIR__ . '/src/functions.php'; 50 | add_action( 'admin_notices', __NAMESPACE__ . '\display_missing_dependencies_notice' ); 51 | return; 52 | } 53 | 54 | // Autoload mapped classes. 55 | spl_autoload_register( __NAMESPACE__ . '\autoloader_classmap' ); 56 | 57 | // Load the WordPress plugin administration API. 58 | require_once ABSPATH . 'wp-admin/includes/plugin.php'; 59 | 60 | // Create a container and register a service provider. 61 | $satispress_container = new Container(); 62 | $satispress_container->register( new ServiceProvider() ); 63 | 64 | // Initialize the plugin and inject the container. 65 | $satispress = plugin() 66 | ->set_basename( plugin_basename( __FILE__ ) ) 67 | ->set_directory( plugin_dir_path( __FILE__ ) ) 68 | ->set_file( __DIR__ . '/satispress.php' ) 69 | ->set_slug( 'satispress' ) 70 | ->set_url( plugin_dir_url( __FILE__ ) ) 71 | ->set_container( $satispress_container ) 72 | ->register_hooks( $satispress_container->get( 'hooks.activation' ) ) 73 | ->register_hooks( $satispress_container->get( 'hooks.deactivation' ) ) 74 | ->register_hooks( $satispress_container->get( 'hooks.authentication' ) ); 75 | 76 | add_action( 'plugins_loaded', [ $satispress, 'compose' ], 5 ); 77 | -------------------------------------------------------------------------------- /src/Authentication/ApiKey/ApiKeyRepository.php: -------------------------------------------------------------------------------- 1 | factory = $factory; 46 | } 47 | 48 | /** 49 | * Find an API Key by its token value. 50 | * 51 | * @since 0.3.0 52 | * 53 | * @param string $token API Key token. 54 | * @return ApiKey|null 55 | */ 56 | public function find_by_token( string $token ) { 57 | $meta_key = static::get_meta_key( $token ); 58 | 59 | $query = new WP_User_Query( 60 | [ 61 | 'number' => 1, 62 | 'count_total' => false, 63 | 'meta_query' => [ 64 | [ 65 | 'key' => $meta_key, 66 | 'compare' => 'EXISTS', 67 | ], 68 | ], 69 | ] 70 | ); 71 | 72 | $users = $query->get_results(); 73 | if ( empty( $users ) ) { 74 | return null; 75 | } 76 | 77 | $user = $users[0]; 78 | $data = get_user_meta( $user->ID, wp_slash( $meta_key ), true ); 79 | 80 | return $this->factory->create( $user, $data, $token ); 81 | } 82 | 83 | /** 84 | * Retrieve all API keys for a given user. 85 | * 86 | * @since 0.3.0 87 | * 88 | * @param WP_User $user WordPress user. 89 | * @return ApiKey[] List of API keys. 90 | */ 91 | public function find_for_user( WP_User $user ): array { 92 | $meta = get_user_meta( $user->ID ); 93 | $keys = []; 94 | 95 | foreach ( $meta as $meta_key => $values ) { 96 | if ( 0 !== strpos( (string) $meta_key, static::META_PREFIX ) ) { 97 | continue; 98 | } 99 | 100 | $token = substr( $meta_key, \strlen( static::META_PREFIX ) ); 101 | $data = maybe_unserialize( $values[0] ); 102 | $keys[] = $this->factory->create( $user, $data, $token ); 103 | } 104 | 105 | return $keys; 106 | } 107 | 108 | /** 109 | * Revoke an API Key. 110 | * 111 | * @since 0.3.0 112 | * 113 | * @param ApiKey $api_key API Key. 114 | */ 115 | public function revoke( ApiKey $api_key ) { 116 | delete_user_meta( 117 | $api_key->get_user()->ID, 118 | static::get_meta_key( $api_key->get_token() ) 119 | ); 120 | } 121 | 122 | /** 123 | * Save an API Key. 124 | * 125 | * @since 0.3.0 126 | * 127 | * @param ApiKey $api_key API Key. 128 | * @return ApiKey API Key. 129 | */ 130 | public function save( ApiKey $api_key ): ApiKey { 131 | update_user_meta( 132 | $api_key->get_user()->ID, 133 | static::get_meta_key( $api_key->get_token() ), 134 | $api_key->get_data() 135 | ); 136 | 137 | return $api_key; 138 | } 139 | 140 | /** 141 | * Retrieve the meta key for saving API key data. 142 | * 143 | * @since 0.3.0 144 | * 145 | * @param string $token API key token. 146 | * @return string 147 | */ 148 | protected static function get_meta_key( string $token ): string { 149 | return static::META_PREFIX . $token; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/Authentication/ApiKey/Server.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 42 | } 43 | 44 | /** 45 | * Check if the server should handle the current request. 46 | * 47 | * @since 0.4.0 48 | * 49 | * @param Request $request Request instance. 50 | * @return bool 51 | */ 52 | public function check_scheme( Request $request ): bool { 53 | $header = $request->get_header( 'authorization' ); 54 | 55 | // Bail if the authorization header doesn't exist. 56 | if ( null === $header || 0 !== stripos( $header, 'basic ' ) ) { 57 | return false; 58 | } 59 | 60 | // The password field isn't used for API Key authentication. 61 | $realm = $request->get_header( 'PHP_AUTH_PW' ); 62 | 63 | // Bail if this isn't a SatisPress authentication request. 64 | if ( 'satispress' !== $realm ) { 65 | return false; 66 | } 67 | 68 | return true; 69 | } 70 | 71 | /** 72 | * Handle authentication. 73 | * 74 | * @since 0.3.0 75 | * 76 | * @param Request $request Request instance. 77 | * @throws AuthenticationException If authentication fails. 78 | * @return int A user ID. 79 | */ 80 | public function authenticate( Request $request ): int { 81 | $api_key_id = $request->get_header( 'PHP_AUTH_USER' ); 82 | 83 | // Bail if an API Key wasn't provided. 84 | if ( null === $api_key_id ) { 85 | throw AuthenticationException::forMissingAuthorizationHeader(); 86 | } 87 | 88 | $api_key = $this->repository->find_by_token( $api_key_id ); 89 | 90 | // Bail if the API Key doesn't exist. 91 | if ( null === $api_key ) { 92 | throw AuthenticationException::forInvalidCredentials(); 93 | } 94 | 95 | $user = $api_key->get_user(); 96 | 97 | // Bail if the user couldn't be determined. 98 | if ( ! $this->validate_user( $user ) ) { 99 | throw AuthenticationException::forInvalidCredentials(); 100 | } 101 | 102 | $this->maybe_update_last_used_time( $api_key ); 103 | 104 | return $user->ID; 105 | } 106 | 107 | /** 108 | * Update the last used time if it's been more than a minute. 109 | * 110 | * @since 0.3.0 111 | * 112 | * @param ApiKey $api_key API Key. 113 | */ 114 | protected function maybe_update_last_used_time( ApiKey $api_key ) { 115 | $timestamp = time(); 116 | $last_used = $api_key['last_used'] ?? 0; 117 | 118 | if ( $timestamp - $last_used < MINUTE_IN_SECONDS ) { 119 | return; 120 | } 121 | 122 | $api_key['last_used'] = $timestamp; 123 | $this->repository->save( $api_key ); 124 | } 125 | 126 | /** 127 | * Whether a user is valid. 128 | * 129 | * @since 0.3.0 130 | * 131 | * @param mixed $user WordPress user instance. 132 | * @return bool 133 | */ 134 | protected function validate_user( $user ): bool { 135 | return ! empty( $user ) && ! is_wp_error( $user ) && $user->exists(); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/Authentication/Server.php: -------------------------------------------------------------------------------- 1 | add_cap( 'administrator', self::DOWNLOAD_PACKAGES ); 66 | $wp_roles->add_cap( 'administrator', self::VIEW_PACKAGES ); 67 | $wp_roles->add_cap( 'administrator', self::MANAGE_OPTIONS ); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Composable.php: -------------------------------------------------------------------------------- 1 | parser = $parser; 37 | } 38 | 39 | /** 40 | * Normalizes a version string to be able to perform comparisons on it. 41 | * 42 | * @since 0.3.0 43 | * 44 | * @throws \UnexpectedValueException Thrown when given an invalid version string. 45 | * 46 | * @param string $version Version string. 47 | * @param string $full_version Optional complete version string to give more context. 48 | * @return string Normalized version string. 49 | */ 50 | public function normalize( string $version, string $full_version = null ): string { 51 | return $this->parser->normalize( $version, $full_version ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Container.php: -------------------------------------------------------------------------------- 1 | offsetGet( $id ); 35 | } 36 | 37 | /** 38 | * Whether the container has an entry for the given identifier. 39 | * 40 | * @since 0.3.0 41 | * 42 | * @param string $id Identifier of the entry to look for. 43 | * @return bool 44 | */ 45 | public function has( $id ): bool { 46 | return $this->offsetExists( $id ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Exception/AuthenticationException.php: -------------------------------------------------------------------------------- 1 | code = $code; 58 | $this->headers = $headers; 59 | 60 | parent::__construct( $message, $status_code, 0, $previous ); 61 | } 62 | 63 | /** 64 | * Create an exception for requests that require authentication. 65 | * 66 | * @since 0.4.0 67 | * 68 | * @param array $headers Response headers. 69 | * @param string $code Optional. The Exception code. 70 | * @param Throwable $previous Optional. The previous throwable used for the exception chaining. 71 | * @return HTTPException 72 | */ 73 | public static function forAuthenticationRequired( 74 | array $headers = [], 75 | string $code = 'invalid_request', 76 | Throwable $previous = null 77 | ): HttpException { 78 | $headers = $headers ?: [ 'WWW-Authenticate' => 'Basic realm="SatisPress"' ]; 79 | $message = 'Authentication is required for this resource.'; 80 | 81 | return new static( $code, $message, HTTP::UNAUTHORIZED, $headers, $previous ); 82 | } 83 | 84 | /** 85 | * Create an exception for invalid credentials. 86 | * 87 | * @since 0.4.0 88 | * 89 | * @param array $headers Response headers. 90 | * @param string $code Optional. The Exception code. 91 | * @param Throwable $previous Optional. The previous throwable used for the exception chaining. 92 | * @return HTTPException 93 | */ 94 | public static function forInvalidCredentials( 95 | array $headers = [], 96 | string $code = 'invalid_credentials', 97 | Throwable $previous = null 98 | ): HttpException { 99 | $headers = $headers ?: [ 'WWW-Authenticate' => 'Basic realm="SatisPress"' ]; 100 | $message = 'Invalid credentials.'; 101 | 102 | return new static( $code, $message, HTTP::UNAUTHORIZED, $headers, $previous ); 103 | } 104 | 105 | /** 106 | * Create an exception for a missing authorization header. 107 | * 108 | * @since 0.4.0 109 | * 110 | * @param array $headers Response headers. 111 | * @param string $code Optional. The Exception code. 112 | * @param Throwable $previous Optional. The previous throwable used for the exception chaining. 113 | * @return HTTPException 114 | */ 115 | public static function forMissingAuthorizationHeader( 116 | array $headers = [], 117 | string $code = 'invalid_credentials', 118 | Throwable $previous = null 119 | ): HttpException { 120 | $headers = $headers ?: [ 'WWW-Authenticate' => 'Basic realm="SatisPress"' ]; 121 | $message = 'Missing authorization header.'; 122 | 123 | return new static( $code, $message, HTTP::UNAUTHORIZED, $headers, $previous ); 124 | } 125 | 126 | /** 127 | * Retrieve the response headers. 128 | * 129 | * @since 0.4.0 130 | * 131 | * @return array Map of header name to header value. 132 | */ 133 | public function getHeaders(): array { 134 | return $this->headers; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Exception/FileDownloadFailed.php: -------------------------------------------------------------------------------- 1 | get_package()->get_name(); 38 | 39 | $message = "Unable to create release artifact for {$name}; source could not be determined."; 40 | 41 | return new static( $message, $code, $previous ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Exception/InvalidReleaseVersion.php: -------------------------------------------------------------------------------- 1 | get_name(); 40 | $message = "Cannot call method {$method} for a package that is not installed; Package: {$name}."; 41 | 42 | return new static( $message, $code, $previous ); 43 | } 44 | 45 | /** 46 | * Create an exception for being unable to archive a package from source. 47 | * 48 | * @since 0.3.0. 49 | * 50 | * @param Package $package Package. 51 | * @param int $code Optional. The Exception code. 52 | * @param \Throwable $previous Optional. The previous throwable used for the exception chaining. 53 | * @return PackageNotInstalled 54 | */ 55 | public static function unableToArchiveFromSource( 56 | Package $package, 57 | int $code = 0, 58 | \Throwable $previous = null 59 | ): PackageNotInstalled { 60 | $name = $package->get_name(); 61 | $message = "Unable to archive {$package}; source does not exist."; 62 | 63 | return new static( $message, $code, $previous ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Exception/SatispressException.php: -------------------------------------------------------------------------------- 1 | message = $message; 46 | $this->status_code = $status_code; 47 | } 48 | 49 | /** 50 | * Display the error message. 51 | * 52 | * @since 0.3.0 53 | */ 54 | public function emit() { 55 | wp_die( 56 | wp_kses_data( $this->message ), 57 | absint( $this->status_code ) 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/HTTP/ResponseBody/FileBody.php: -------------------------------------------------------------------------------- 1 | filename = $filename; 44 | } 45 | 46 | /** 47 | * Stream the file. 48 | * 49 | * @since 0.3.0 50 | */ 51 | public function emit() { 52 | $this->configure_environment(); 53 | $this->clean_buffers(); 54 | $this->readfile_chunked( $this->filename ); 55 | } 56 | 57 | /** 58 | * Configure the environment before sending a file. 59 | * 60 | * @since 0.3.0 61 | */ 62 | protected function configure_environment() { 63 | session_write_close(); 64 | 65 | if ( $this->function_exists( 'apache_setenv' ) ) { 66 | // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_apache_setenv 67 | apache_setenv( 'no-gzip', '1' ); 68 | } 69 | 70 | if ( $this->function_exists( 'ini_set' ) ) { 71 | // phpcs:ignore WordPress.PHP.IniSet.Risky 72 | ini_set( 'zlib.output_compression', 'Off' ); 73 | } 74 | 75 | if ( $this->function_exists( 'set_time_limit' ) ) { 76 | set_time_limit( 0 ); 77 | } 78 | } 79 | 80 | /** 81 | * Clean output buffers. 82 | * 83 | * @since 0.3.0 84 | */ 85 | protected function clean_buffers() { 86 | $levels = ob_get_level(); 87 | for ( $i = 0; $i < $levels; $i++ ) { 88 | ob_end_clean(); 89 | } 90 | } 91 | 92 | /** 93 | * Output a file. 94 | * 95 | * Reads file in chunks so big downloads are possible without changing `php.ini`. 96 | * 97 | * @link https://github.com/bcit-ci/CodeIgniter/wiki/Download-helper-for-large-files 98 | * 99 | * @since 0.3.0 100 | * 101 | * @param string $filename Absolute path to a file. 102 | */ 103 | protected function readfile_chunked( string $filename ) { 104 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen 105 | $handle = fopen( $filename, 'rb' ); 106 | if ( false === $handle ) { 107 | return; 108 | } 109 | 110 | while ( ! feof( $handle ) ) { 111 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread 112 | $buffer = fread( $handle, MB_IN_BYTES ); 113 | // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped 114 | echo $buffer; 115 | flush(); 116 | } 117 | 118 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose 119 | fclose( $handle ); 120 | } 121 | 122 | /** 123 | * Whether a function exists and is enabled. 124 | * 125 | * @since 0.3.0 126 | * 127 | * @param string $name Function name. 128 | * @return bool 129 | */ 130 | protected function function_exists( string $name ): bool { 131 | return \function_exists( $name ) && false === strpos( ini_get( 'disable_functions' ), $name ); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/HTTP/ResponseBody/JsonBody.php: -------------------------------------------------------------------------------- 1 | data = $data; 36 | } 37 | 38 | /** 39 | * Emit the data as a JSON-serialized string. 40 | * 41 | * @since 0.3.0 42 | */ 43 | public function emit() { 44 | echo wp_json_encode( $this->data ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/HTTP/ResponseBody/NullBody.php: -------------------------------------------------------------------------------- 1 | path = $path; 49 | } 50 | 51 | /** 52 | * Add rules to .htaccess. 53 | * 54 | * @since 0.2.0 55 | * 56 | * @param array $rules List of rules to add. 57 | */ 58 | public function add_rules( array $rules ) { 59 | $this->rules = array_merge( $this->rules, $rules ); 60 | } 61 | 62 | /** 63 | * Retrieve the full path to the .htaccess file itself. 64 | * 65 | * @since 0.2.0 66 | * 67 | * @return string 68 | */ 69 | public function get_file(): string { 70 | return $this->path . '.htaccess'; 71 | } 72 | 73 | /** 74 | * Retrieve the rules in the .htaccess file. 75 | * 76 | * Only contains the rules between the #SatisPress delimiters. 77 | * 78 | * @since 0.2.0 79 | * 80 | * @return array 81 | */ 82 | public function get_rules(): array { 83 | return (array) apply_filters( 'satispress_htaccess_rules', $this->rules ); 84 | } 85 | 86 | /** 87 | * Determine if the .htaccess file exists. 88 | * 89 | * @since 0.3.0 90 | * 91 | * @return bool True if the file exists, false otherwise. 92 | */ 93 | public function file_exists(): bool { 94 | return file_exists( $this->get_file() ); 95 | } 96 | 97 | /** 98 | * Determine if the .htaccess file is writable. 99 | * 100 | * @since 0.2.0 101 | * 102 | * @return bool 103 | */ 104 | public function is_writable(): bool { 105 | $file = $this->get_file(); 106 | 107 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable 108 | return ( ! $this->file_exists() && is_writable( $this->path ) ) || is_writable( $file ); 109 | } 110 | 111 | /** 112 | * Save rules to the .htaccess file. 113 | * 114 | * @since 0.2.0 115 | */ 116 | public function save() { 117 | $rules = $this->get_rules(); 118 | insert_with_markers( $this->get_file(), 'SatisPress', $rules ); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Integration/EnvatoMarket.php: -------------------------------------------------------------------------------- 1 | get_bearer_args( $vars['item_id'] ); 55 | $envato_download_url = envato_market()->api()->download( $vars['item_id'], $args ); 56 | // Envato returns false, if the download fails. i.e id is missing, product does not exist anymore. 57 | if ( $envato_download_url ) { 58 | $download_url = $envato_download_url; 59 | } 60 | } 61 | } 62 | 63 | return $download_url; 64 | } 65 | 66 | /** 67 | * Retrieves the bearer arguments for a request with a single use API Token. 68 | * 69 | * @since 0.7.0 70 | * 71 | * @link https://build.envato.com/api/#market_0_getBuyerDownload 72 | * @see Envato_Market_Admin::set_bearer_args() 73 | * 74 | * @param string $id Item id. 75 | * @return array 76 | */ 77 | protected function get_bearer_args( string $id ): array { 78 | $token = ''; 79 | $items = envato_market()->get_option( 'items', [] ); 80 | 81 | foreach ( $items as $item ) { 82 | if ( (int) $item['id'] === (int) $id ) { 83 | $token = $item['token']; 84 | break; 85 | } 86 | } 87 | 88 | if ( empty( $token ) ) { 89 | return []; 90 | } 91 | 92 | return [ 93 | 'headers' => [ 94 | 'Authorization' => 'Bearer ' . $token, 95 | ], 96 | ]; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Integration/Members.php: -------------------------------------------------------------------------------- 1 | esc_html__( 'SatisPress', 'satispress' ), 45 | 'caps' => [], 46 | 'icon' => 'dashicons-admin-generic', 47 | 'priority' => 50, 48 | ] 49 | ); 50 | } 51 | 52 | /** 53 | * Register capabilities for the Members plugin. 54 | * 55 | * @since 0.3.0 56 | * 57 | * @link https://wordpress.org/plugins/members/ 58 | */ 59 | public function register_capabilities() { 60 | members_register_cap( 61 | Capabilities::DOWNLOAD_PACKAGES, 62 | [ 63 | 'label' => esc_html__( 'Download Packages', 'satispress' ), 64 | 'group' => 'satispress', 65 | ] 66 | ); 67 | 68 | members_register_cap( 69 | Capabilities::VIEW_PACKAGES, 70 | [ 71 | 'label' => esc_html__( 'View Packages', 'satispress' ), 72 | 'group' => 'satispress', 73 | ] 74 | ); 75 | 76 | members_register_cap( 77 | Capabilities::MANAGE_OPTIONS, 78 | [ 79 | 'label' => esc_html__( 'Manage Options', 'satispress' ), 80 | 'group' => 'satispress', 81 | ] 82 | ); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Package.php: -------------------------------------------------------------------------------- 1 | release_manager = $release_manager; 43 | } 44 | 45 | /** 46 | * Create a package builder. 47 | * 48 | * @since 0.3.0 49 | * 50 | * @param string $package_type Package type. 51 | * @return PluginBuilder|ThemeBuilder|PackageBuilder Package builder instance. 52 | */ 53 | public function create( string $package_type ): PackageBuilder { 54 | switch ( $package_type ) { 55 | case 'plugin': 56 | return new PluginBuilder( new Plugin(), $this->release_manager ); 57 | case 'theme': 58 | return new ThemeBuilder( new Theme(), $this->release_manager ); 59 | } 60 | 61 | return new PackageBuilder( new BasePackage(), $this->release_manager ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/PackageType/Plugin.php: -------------------------------------------------------------------------------- 1 | basename; 39 | } 40 | 41 | /** 42 | * Retrieve the list of files in the plugin. 43 | * 44 | * @since 0.3.0 45 | * 46 | * @param array $excludes Optional. Array of file names to exclude. 47 | * @return array 48 | */ 49 | public function get_files( array $excludes = [] ): array { 50 | // Single-file plugins should only include the main plugin file. 51 | if ( $this->is_single_file() ) { 52 | return [ $this->get_path( $this->get_basename() ) ]; 53 | } 54 | 55 | return parent::get_files( $excludes ); 56 | } 57 | 58 | /** 59 | * Whether the plugin is a single-file plugin. 60 | * 61 | * @since 0.3.0 62 | * 63 | * @return bool 64 | */ 65 | public function is_single_file(): bool { 66 | return false === strpos( $this->get_basename(), '/' ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/PackageType/PluginBuilder.php: -------------------------------------------------------------------------------- 1 | set( 'basename', $basename ); 32 | } 33 | 34 | /** 35 | * Create a plugin from source. 36 | * 37 | * @since 0.3.0 38 | * 39 | * @param string $plugin_file Relative path to the main plugin file. 40 | * @param array $plugin_data Optional. Array of plugin data. 41 | * @return PluginBuilder 42 | */ 43 | public function from_source( string $plugin_file, array $plugin_data = [] ): self { 44 | $slug = $this->get_slug_from_plugin_file( $plugin_file ); 45 | 46 | // Account for single-file plugins. 47 | $directory = '.' === \dirname( $plugin_file ) ? '' : \dirname( $plugin_file ); 48 | 49 | if ( empty( $plugin_data ) ) { 50 | $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_file ); 51 | } 52 | 53 | return $this 54 | ->set_author( $plugin_data['AuthorName'] ) 55 | ->set_author_url( $plugin_data['AuthorURI'] ) 56 | ->set_basename( $plugin_file ) 57 | ->set_description( $plugin_data['Description'] ) 58 | ->set_directory( WP_PLUGIN_DIR . '/' . $directory ) 59 | ->set_name( $plugin_data['Name'] ) 60 | ->set_homepage( $plugin_data['PluginURI'] ) 61 | ->set_installed( true ) 62 | ->set_installed_version( $plugin_data['Version'] ) 63 | ->set_slug( $slug ) 64 | ->set_type( 'plugin' ) 65 | ->add_cached_releases(); 66 | } 67 | 68 | /** 69 | * Set properties from an existing package. 70 | * 71 | * @since 0.3.0 72 | * 73 | * @param Package $package Package. 74 | * @return $this 75 | */ 76 | public function with_package( Package $package ): PackageBuilder { 77 | parent::with_package( $package ); 78 | 79 | if ( $package instanceof Plugin ) { 80 | $this->set_basename( $package->get_basename() ); 81 | } 82 | 83 | return $this; 84 | } 85 | 86 | /** 87 | * Retrieve a plugin slug. 88 | * 89 | * @since 0.3.0 90 | * 91 | * @param string $plugin_file Plugin slug or relative path to the main plugin 92 | * file from the plugins directory. 93 | * @return string 94 | */ 95 | protected function get_slug_from_plugin_file( $plugin_file ): string { 96 | if ( ! is_plugin_file( $plugin_file ) ) { 97 | return $plugin_file; 98 | } 99 | 100 | $slug = \dirname( $plugin_file ); 101 | 102 | // Account for single file plugins. 103 | $slug = '.' === $slug ? basename( $plugin_file, '.php' ) : $slug; 104 | 105 | return $slug; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/PackageType/Theme.php: -------------------------------------------------------------------------------- 1 | set_author( $theme->get( 'Author' ) ) 38 | ->set_author_url( $theme->get( 'AuthorURI' ) ) 39 | ->set_description( $theme->get( 'Description' ) ) 40 | ->set_directory( get_theme_root() . '/' . $slug ) 41 | ->set_name( $theme->get( 'Name' ) ) 42 | ->set_homepage( $theme->get( 'ThemeURI' ) ) 43 | ->set_installed( true ) 44 | ->set_installed_version( $theme->get( 'Version' ) ) 45 | ->set_slug( $slug ) 46 | ->set_type( 'theme' ) 47 | ->add_cached_releases(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Plugin.php: -------------------------------------------------------------------------------- 1 | get_container(); 30 | 31 | /** 32 | * Start composing the object graph in SatisPress. 33 | * 34 | * @since 0.3.0 35 | * 36 | * @param Plugin $plugin Main plugin instance. 37 | * @param ContainerInterface $container Dependency container. 38 | */ 39 | do_action( 'satispress_compose', $this, $container ); 40 | 41 | // Register hook providers. 42 | $this 43 | ->register_hooks( $container->get( 'hooks.i18n' ) ) 44 | ->register_hooks( $container->get( 'hooks.capabilities' ) ) 45 | ->register_hooks( $container->get( 'hooks.rewrite_rules' ) ) 46 | ->register_hooks( $container->get( 'hooks.custom_vendor' ) ) 47 | ->register_hooks( $container->get( 'hooks.health_check' ) ) 48 | ->register_hooks( $container->get( 'hooks.request_handler' ) ) 49 | ->register_hooks( $container->get( 'hooks.rest' ) ) 50 | ->register_hooks( $container->get( 'hooks.package_archiver' ) ); 51 | 52 | if ( is_admin() ) { 53 | $this 54 | ->register_hooks( $container->get( 'hooks.upgrade' ) ) 55 | ->register_hooks( $container->get( 'hooks.admin_assets' ) ) 56 | ->register_hooks( $container->get( 'screen.edit_user' ) ) 57 | ->register_hooks( $container->get( 'screen.settings' ) ); 58 | } 59 | 60 | if ( \function_exists( 'envato_market' ) ) { 61 | $this->register_hooks( $container->get( 'plugin.envato_market' ) ); 62 | } 63 | 64 | if ( \function_exists( 'members_plugin' ) ) { 65 | $this->register_hooks( $container->get( 'plugin.members' ) ); 66 | } 67 | 68 | /** 69 | * Finished composing the object graph in SatisPress. 70 | * 71 | * @since 0.3.0 72 | * 73 | * @param Plugin $plugin Main plugin instance. 74 | * @param ContainerInterface $container Dependency container. 75 | */ 76 | do_action( 'satispress_composed', $this, $container ); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Provider/Activation.php: -------------------------------------------------------------------------------- 1 | plugin->get_file(), [ $this, 'activate' ] ); 30 | } 31 | 32 | /** 33 | * Activate the plugin. 34 | * 35 | * - Sets a flag to flush rewrite rules after plugin rewrite rules have been 36 | * registered. 37 | * - Registers capabilities for the admin role. 38 | * 39 | * @see \SatisPress\Provider\RewriteRules::maybe_flush_rewrite_rules() 40 | * 41 | * @since 0.3.0 42 | */ 43 | public function activate() { 44 | update_option( 'satispress_flush_rewrite_rules', 'yes' ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Provider/AdminAssets.php: -------------------------------------------------------------------------------- 1 | plugin->get_url( 'assets/js/admin.js' ), 39 | [ 'jquery' ], 40 | '20210215', 41 | true 42 | ); 43 | 44 | wp_register_script( 45 | 'satispress-access', 46 | $this->plugin->get_url( 'assets/js/access.js' ), 47 | [ 'wp-components', 'wp-data', 'wp-data-controls', 'wp-element', 'wp-i18n' ], 48 | '20210211', 49 | true 50 | ); 51 | 52 | wp_set_script_translations( 53 | 'satispress-access', 54 | 'satispress', 55 | $this->plugin->get_path( 'languages' ) 56 | ); 57 | 58 | wp_register_script( 59 | 'satispress-repository', 60 | $this->plugin->get_url( 'assets/js/repository.js' ), 61 | [ 'wp-components', 'wp-data', 'wp-data-controls', 'wp-element', 'wp-i18n' ], 62 | '20210211', 63 | true 64 | ); 65 | 66 | wp_set_script_translations( 67 | 'satispress-repository', 68 | 'satispress', 69 | $this->plugin->get_path( 'languages' ) 70 | ); 71 | 72 | wp_register_style( 73 | 'satispress-admin', 74 | $this->plugin->get_url( 'assets/css/admin.css' ), 75 | [ 'wp-components' ], 76 | '20180816' 77 | ); 78 | } 79 | 80 | /** 81 | * Filter script tag type attributes. 82 | * 83 | * @since 1.0.0 84 | * 85 | * @param string $tag Script tag HTML. 86 | * @param string $handle Script identifier. 87 | * @return string 88 | */ 89 | public function filter_script_type( string $tag, string $handle ): string { 90 | $modules = [ 91 | 'satispress-access', 92 | 'satispress-repository', 93 | ]; 94 | 95 | if ( in_array( $handle, $modules, true ) ) { 96 | $tag = str_replace( 'plugin->get_file(), [ $this, 'deactivate' ] ); 29 | } 30 | 31 | /** 32 | * Deactivation routine. 33 | * 34 | * Deleting the rewrite rules option should force WordPress to regenerate 35 | * them next time they're needed. 36 | * 37 | * @since 0.3.0 38 | */ 39 | public function deactivate() { 40 | delete_option( 'rewrite_rules' ); 41 | delete_option( 'satispress_flush_rewrite_rules' ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Provider/REST.php: -------------------------------------------------------------------------------- 1 | controllers = $controllers; 38 | } 39 | 40 | /** 41 | * Register hooks. 42 | * 43 | * @since 1.0.0 44 | */ 45 | public function register_hooks() { 46 | add_action( 'rest_api_init', [ $this, 'register_rest_controllers' ] ); 47 | } 48 | 49 | /** 50 | * Register REST controllers. 51 | * 52 | * @since 1.0.0 53 | * 54 | * @throws \LogicException If a registered controller doesn't extend WP_REST_Controller. 55 | */ 56 | public function register_rest_controllers() { 57 | foreach ( $this->controllers as $controller ) { 58 | if ( ! $controller instanceof WP_REST_Controller ) { 59 | throw new \LogicException( 'Authentication servers must implement \WP_REST_Controller.' ); 60 | } 61 | 62 | $controller->register_routes(); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Provider/RewriteRules.php: -------------------------------------------------------------------------------- 1 | add_external_rule( 82 | 'satispress/packages.json$', 83 | 'index.php?satispress_route=composer' 84 | ); 85 | } 86 | 87 | /** 88 | * Flush the rewrite rules if needed. 89 | * 90 | * @since 0.3.0 91 | */ 92 | public function maybe_flush_rewrite_rules() { 93 | if ( is_network_admin() || 'no' === get_option( 'satispress_flush_rewrite_rules' ) ) { 94 | return; 95 | } 96 | 97 | update_option( 'satispress_flush_rewrite_rules', 'no' ); 98 | flush_rewrite_rules(); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Release.php: -------------------------------------------------------------------------------- 1 | package = $package; 52 | $this->version = $version; 53 | $this->source_url = $source_url; 54 | } 55 | 56 | /** 57 | * Retrieve the URL to download the release. 58 | * 59 | * @since 0.3.0 60 | * 61 | * @param array $args Query parameters to add to the URL. 62 | * @return string 63 | */ 64 | public function get_download_url( array $args = [] ): string { 65 | $url = sprintf( 66 | '/satispress/%s/%s', 67 | $this->get_package()->get_slug(), 68 | $this->get_version() 69 | ); 70 | 71 | return add_query_arg( $args, network_home_url( $url ) ); 72 | } 73 | 74 | /** 75 | * Retrieve the relative path to a release artifact. 76 | * 77 | * @since 0.3.0 78 | * 79 | * @return string 80 | */ 81 | public function get_file_path(): string { 82 | return sprintf( 83 | '%1$s/%2$s', 84 | $this->get_package()->get_slug(), 85 | $this->get_file() 86 | ); 87 | } 88 | 89 | /** 90 | * Retrieve the name of the file. 91 | * 92 | * @since 0.3.0 93 | * 94 | * @return string 95 | */ 96 | public function get_file(): string { 97 | return sprintf( 98 | '%1$s-%2$s.zip', 99 | $this->get_package()->get_slug(), 100 | $this->get_version() 101 | ); 102 | } 103 | 104 | /** 105 | * Retrieve the package. 106 | * 107 | * @since 0.3.0 108 | * 109 | * @return Package 110 | */ 111 | public function get_package(): Package { 112 | return $this->package; 113 | } 114 | 115 | /** 116 | * Retrieve the source URL for a release. 117 | * 118 | * @since 0.3.0 119 | * 120 | * @return string 121 | */ 122 | public function get_source_url(): string { 123 | return $this->source_url; 124 | } 125 | 126 | /** 127 | * Retrieve the version number for the release. 128 | * 129 | * @since 0.3.0 130 | * 131 | * @return string 132 | */ 133 | public function get_version(): string { 134 | return $this->version; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/ReleaseManager.php: -------------------------------------------------------------------------------- 1 | archiver = $archiver; 49 | $this->storage = $storage; 50 | } 51 | 52 | /** 53 | * Retrieve all releases for a package. 54 | * 55 | * @since 0.3.0 56 | * 57 | * @param Package $package Package instance. 58 | * @return Release[] 59 | */ 60 | public function all( Package $package ): array { 61 | $releases = []; 62 | 63 | foreach ( $this->storage->list_files( $package->get_slug() ) as $filename ) { 64 | $version = str_replace( $package->get_slug() . '-', '', basename( $filename, '.zip' ) ); 65 | $releases[ $version ] = new Release( $package, $version ); 66 | } 67 | 68 | return $releases; 69 | } 70 | 71 | /** 72 | * Archive a release. 73 | * 74 | * @since 0.3.0 75 | * 76 | * @param Release $release Release instance. 77 | * @throws InvalidReleaseSource If a source URL is not available or the 78 | * version doesn't match the currently installed version. 79 | * @throws FileOperationFailed If the release artifact can't be moved to storage. 80 | * @return Release 81 | */ 82 | public function archive( Release $release ): Release { 83 | if ( $this->exists( $release ) ) { 84 | return $release; 85 | } 86 | 87 | $package = $release->get_package(); 88 | $source_url = $release->get_source_url(); 89 | 90 | if ( ! empty( $source_url ) ) { 91 | $filename = $this->archiver->archive_from_url( $release ); 92 | } elseif ( $package->is_installed() && $package->is_installed_release( $release ) ) { 93 | $filename = $this->archiver->archive_from_source( $package, $release->get_version() ); 94 | } else { 95 | throw InvalidReleaseSource::forRelease( $release ); 96 | } 97 | 98 | if ( ! $this->storage->move( $filename, $release->get_file_path() ) ) { 99 | throw FileOperationFailed::unableToMoveReleaseArtifactToStorage( $filename, $release->get_file_path() ); 100 | } 101 | 102 | return $release; 103 | } 104 | 105 | /** 106 | * Retrieve a checksum for a release. 107 | * 108 | * @since 0.3.0 109 | * 110 | * @param string $algorithm Algorithm. 111 | * @param Release $release Release instance. 112 | * @return string 113 | */ 114 | public function checksum( string $algorithm, Release $release ): string { 115 | return $this->storage->checksum( $algorithm, $release->get_file_path() ); 116 | } 117 | 118 | /** 119 | * Whether an artifact exists for a given release. 120 | * 121 | * @param Release $release Release instance. 122 | * @return bool 123 | */ 124 | public function exists( Release $release ): bool { 125 | return $this->storage->exists( $release->get_file_path() ); 126 | } 127 | 128 | /** 129 | * Send a download. 130 | * 131 | * @since 0.3.0 132 | * 133 | * @param Release $release Release instance. 134 | * @return Response 135 | */ 136 | public function send( Release $release ): Response { 137 | do_action( 'satispress_send_release', $release ); 138 | return $this->storage->send( $release->get_file_path() ); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Repository/AbstractRepository.php: -------------------------------------------------------------------------------- 1 | where( $args ) ); 43 | } 44 | 45 | /** 46 | * Retrieve items that match a list of key/value pairs. 47 | * 48 | * @since 0.3.0 49 | * 50 | * @param array $args Map of key/value pairs. 51 | * @return array 52 | */ 53 | public function where( array $args ): array { 54 | $args = $this->parse_args( $args ); 55 | $matches = []; 56 | $args_count = count( $args ); 57 | 58 | foreach ( $this->all() as $item ) { 59 | $matched = 0; 60 | 61 | foreach ( $args as $key => $value ) { 62 | if ( $item[ $key ] && $value === $item[ $key ] ) { 63 | $matched++; // phpcs:ignore Universal.Operators.DisallowStandalonePostIncrementDecrement 64 | } 65 | } 66 | 67 | if ( $matched === $args_count ) { 68 | $matches[] = $item; 69 | } 70 | } 71 | 72 | return $matches; 73 | } 74 | 75 | /** 76 | * Retrieve the first item to match a list of key/value pairs. 77 | * 78 | * @since 0.3.0 79 | * 80 | * @param array $args Map of key/value pairs. 81 | * @return Package|null 82 | */ 83 | public function first_where( array $args ) { 84 | $items = $this->where( $args ); 85 | return empty( $items ) ? null : reset( $items ); 86 | } 87 | 88 | /** 89 | * Apply a callback to a repository to filter items. 90 | * 91 | * @since 0.3.0 92 | * 93 | * @param callable $callback Filter callback. 94 | * @return PackageRepository 95 | */ 96 | public function with_filter( callable $callback ): PackageRepository { 97 | return new FilteredRepository( $this, $callback ); 98 | } 99 | 100 | /** 101 | * Parse arguments used for filtering a collection. 102 | * 103 | * @since 0.3.0 104 | * 105 | * @param array $args List of key/value pairs. 106 | * @return array 107 | */ 108 | protected function parse_args( array $args ): array { 109 | // If a plugin file is passed as the slug value, convert it to a 110 | // basename argument. 111 | if ( isset( $args['slug'] ) && is_plugin_file( $args['slug'] ) ) { 112 | $args['basename'] = $args['slug']; 113 | unset( $args['slug'] ); 114 | } 115 | 116 | return $args; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Repository/CachedRepository.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 52 | } 53 | 54 | /** 55 | * Retrieve all items. 56 | * 57 | * @since 0.3.0 58 | * 59 | * @return Package[] 60 | */ 61 | public function all(): array { 62 | if ( $this->initialized ) { 63 | return $this->items; 64 | } 65 | 66 | $this->initialized = true; 67 | $this->items = $this->repository->all(); 68 | 69 | return $this->items; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Repository/FilteredRepository.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 46 | $this->callback = $callback; 47 | } 48 | 49 | /** 50 | * Retrieve all packages in the repository. 51 | * 52 | * @since 0.3.0 53 | * 54 | * @return Package[] 55 | */ 56 | public function all(): array { 57 | $packages = []; 58 | 59 | foreach ( $this->repository->all() as $package ) { 60 | if ( ( $this->callback )( $package ) ) { 61 | $packages[] = $package; 62 | } 63 | } 64 | 65 | return $packages; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Repository/InstalledPlugins.php: -------------------------------------------------------------------------------- 1 | factory = $factory; 40 | } 41 | 42 | /** 43 | * Retrieve all installed plugins. 44 | * 45 | * @since 0.3.0 46 | * 47 | * @return Package[] 48 | */ 49 | public function all(): array { 50 | $items = []; 51 | 52 | foreach ( get_plugins() as $plugin_file => $plugin_data ) { 53 | $package = $this->build( $plugin_file, $plugin_data ); 54 | $items[] = $package; 55 | } 56 | 57 | ksort( $items ); 58 | 59 | return $items; 60 | } 61 | 62 | /** 63 | * Build a plugin. 64 | * 65 | * @since 0.3.0 66 | * 67 | * @param string $plugin_file Relative path to a plugin file. 68 | * @param array $plugin_data Plugin data. 69 | * @return Plugin|Package 70 | */ 71 | protected function build( string $plugin_file, array $plugin_data ): Plugin { 72 | return $this->factory->create( 'plugin' ) 73 | ->from_source( $plugin_file, $plugin_data ) 74 | ->build(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Repository/InstalledThemes.php: -------------------------------------------------------------------------------- 1 | factory = $factory; 41 | } 42 | 43 | /** 44 | * Retrieve all installed themes. 45 | * 46 | * @since 0.3.0 47 | * 48 | * @return Package[] 49 | */ 50 | public function all(): array { 51 | $items = []; 52 | 53 | foreach ( wp_get_themes() as $slug => $theme ) { 54 | $items[] = $this->build( $slug, $theme ); 55 | } 56 | 57 | return $items; 58 | } 59 | 60 | /** 61 | * Build a theme. 62 | * 63 | * @since 0.3.0 64 | * 65 | * @param string $slug Theme slug. 66 | * @param WP_Theme $theme WP theme instance. 67 | * @return Theme|Package 68 | */ 69 | protected function build( string $slug, WP_Theme $theme ): Theme { 70 | return $this->factory->create( 'theme' ) 71 | ->from_source( $slug, $theme ) 72 | ->build(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Repository/MultiRepository.php: -------------------------------------------------------------------------------- 1 | repositories = $repositories; 38 | } 39 | 40 | /** 41 | * Retrieve all packages in the repository. 42 | * 43 | * @since 0.3.0 44 | * 45 | * @return Package[] 46 | */ 47 | public function all(): array { 48 | $packages = []; 49 | 50 | foreach ( $this->repositories as $repository ) { 51 | $packages = array_merge( $packages, $repository->all() ); 52 | } 53 | 54 | return $packages; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Repository/PackageRepository.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 53 | $this->transformer = $transformer; 54 | } 55 | 56 | /** 57 | * Handle a request to the packages.json endpoint. 58 | * 59 | * @since 0.3.0 60 | * 61 | * @param Request $request HTTP request instance. 62 | * @throws HttpException If the user doesn't have permission to view packages. 63 | * @return Response 64 | */ 65 | public function handle( Request $request ): Response { 66 | if ( ! current_user_can( Capabilities::VIEW_PACKAGES ) ) { 67 | throw HttpException::forForbiddenResource(); 68 | } 69 | 70 | return new Response( 71 | new JsonBody( $this->transformer->transform( $this->repository ) ), 72 | HTTP::OK, 73 | [ 'Content-Type' => 'application/json; charset=' . get_option( 'blog_charset' ) ] 74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Route/Download.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 75 | $this->release_manager = $release_manager; 76 | } 77 | 78 | /** 79 | * Process a download request. 80 | * 81 | * Determines if the current request is for packages.json or a whitelisted 82 | * package and routes it to the appropriate method. 83 | * 84 | * @since 0.3.0 85 | * 86 | * @param Request $request HTTP request instance. 87 | * @throws HttpException For invalid parameters or the user doesn't have 88 | * permission to download the requested file. 89 | * @return Response 90 | */ 91 | public function handle( Request $request ): Response { 92 | if ( ! current_user_can( Capabilities::DOWNLOAD_PACKAGES ) ) { 93 | throw HttpException::forForbiddenResource(); 94 | } 95 | 96 | $slug = preg_replace( self::PACKAGE_SLUG_REGEX, '', $request['slug'] ); 97 | if ( empty( $slug ) ) { 98 | throw HttpException::forUnknownPackage( $slug ); 99 | } 100 | 101 | $version = ''; 102 | if ( ! empty( $request['version'] ) ) { 103 | $version = preg_replace( self::PACKAGE_VERSION_REGEX, '', $request['version'] ); 104 | } 105 | 106 | $package = $this->repository->first_where( [ 'slug' => $slug ] ); 107 | 108 | // Send a 404 response if the package doesn't exist. 109 | if ( ! $package instanceof Package ) { 110 | throw HttpException::forUnknownPackage( $slug ); 111 | } 112 | 113 | return $this->send_package( $package, $version ); 114 | } 115 | 116 | /** 117 | * Send a package zip. 118 | * 119 | * Sends a 404 header if the specified version isn't available. 120 | * 121 | * @since 0.3.0 122 | * 123 | * @param Package $package Package object. 124 | * @param string $version Version of the package to send. 125 | * @throws HttpException For invalid or missing releases. 126 | * @return Response 127 | */ 128 | protected function send_package( Package $package, string $version ): Response { 129 | if ( self::LATEST_VERSION === $version ) { 130 | $version = $package->get_latest_release()->get_version(); 131 | } 132 | 133 | try { 134 | $release = $package->get_release( $version ); 135 | } catch ( InvalidReleaseVersion $e ) { 136 | throw HttpException::forInvalidRelease( $package, $version ); 137 | } 138 | 139 | // Ensure the user has access to download the release. 140 | if ( ! current_user_can( Capabilities::DOWNLOAD_PACKAGE, $package, $release ) ) { 141 | throw HttpException::forForbiddenPackage( $package ); 142 | } 143 | 144 | try { 145 | // Cache the release if an artifact doesn't already exist. 146 | $release = $this->release_manager->archive( $release ); 147 | } catch ( SatispressException $e ) { 148 | // Send a 404 if the release isn't available. 149 | throw HttpException::forMissingRelease( $release ); 150 | } 151 | 152 | return $this->release_manager->send( $release ); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/Route/Route.php: -------------------------------------------------------------------------------- 1 | api_keys = $api_keys; 43 | } 44 | 45 | /** 46 | * Register hooks. 47 | * 48 | * @since 0.3.0 49 | */ 50 | public function register_hooks() { 51 | $user_id = get_edited_user_id(); 52 | 53 | // Only load the screen for users that can view or download packages. 54 | if ( 55 | ! user_can( $user_id, Capabilities::DOWNLOAD_PACKAGES ) 56 | && ! user_can( $user_id, Capabilities::VIEW_PACKAGES ) 57 | ) { 58 | return; 59 | } 60 | 61 | add_action( 'load-profile.php', [ $this, 'load_screen' ] ); 62 | add_action( 'load-user-edit.php', [ $this, 'load_screen' ] ); 63 | } 64 | 65 | /** 66 | * Set up the screen. 67 | * 68 | * @since 0.3.0 69 | */ 70 | public function load_screen() { 71 | add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] ); 72 | add_action( 'edit_user_profile', [ $this, 'render_api_keys_section' ] ); 73 | add_action( 'show_user_profile', [ $this, 'render_api_keys_section' ] ); 74 | } 75 | 76 | /** 77 | * Enqueue assets. 78 | * 79 | * @since 0.3.0 80 | */ 81 | public function enqueue_assets() { 82 | wp_enqueue_script( 'satispress-admin' ); 83 | wp_enqueue_style( 'satispress-admin' ); 84 | 85 | wp_enqueue_script( 'satispress-access' ); 86 | 87 | wp_localize_script( 88 | 'satispress-access', 89 | '_satispressAccessData', 90 | [ 91 | 'editedUserId' => get_edited_user_id(), 92 | ] 93 | ); 94 | 95 | preload_rest_data( 96 | [ 97 | '/satispress/v1/apikeys?user=' . get_edited_user_id(), 98 | ] 99 | ); 100 | } 101 | 102 | /** 103 | * Display the API Keys section. 104 | * 105 | * @param WP_User $user WordPress user instance. 106 | */ 107 | public function render_api_keys_section( WP_User $user ) { 108 | printf( '

    %s

    ', esc_html__( 'SatisPress API Keys', 'satispress' ) ); 109 | echo '
    '; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Storage/Local.php: -------------------------------------------------------------------------------- 1 | set_base_directory( $base_directory ); 40 | } 41 | 42 | /** 43 | * Retrieve the hash value of the contents of a file. 44 | * 45 | * @since 0.3.0 46 | * 47 | * @param string $algorithm Algorithm. 48 | * @param string $file Relative file path. 49 | * @throws FileNotFound If the file doesn't exist. 50 | * @return string 51 | */ 52 | public function checksum( string $algorithm, string $file ): string { 53 | $filename = $this->get_absolute_path( $file ); 54 | 55 | if ( ! file_exists( $filename ) ) { 56 | throw FileNotFound::forInvalidChecksum( $filename ); 57 | } 58 | 59 | return hash_file( $algorithm, $filename ); 60 | } 61 | 62 | /** 63 | * Delete a file. 64 | * 65 | * @since 0.3.0 66 | * 67 | * @param string $file Relative file path. 68 | * @return bool 69 | */ 70 | public function delete( string $file ): bool { 71 | // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink 72 | return unlink( $this->get_absolute_path( $file ) ); 73 | } 74 | 75 | /** 76 | * Whether a file exists. 77 | * 78 | * @since 0.3.0 79 | * 80 | * @param string $file Relative file path. 81 | * @return bool 82 | */ 83 | public function exists( string $file ): bool { 84 | $filename = $this->get_absolute_path( $file ); 85 | return file_exists( $filename ); 86 | } 87 | 88 | /** 89 | * List files. 90 | * 91 | * @since 0.3.0 92 | * 93 | * @param string $path Relative path. 94 | * @return array Array of relative file paths. 95 | */ 96 | public function list_files( string $path ): array { 97 | $directory = $this->get_absolute_path( $path ); 98 | if ( ! file_exists( $directory ) ) { 99 | return []; 100 | } 101 | 102 | $iterator = new DirectoryIterator( $directory ); 103 | if ( iterator_count( $iterator ) < 1 ) { 104 | return []; 105 | } 106 | 107 | $files = []; 108 | foreach ( $iterator as $fileinfo ) { 109 | if ( ! $fileinfo->isFile() || 'zip' !== $fileinfo->getExtension() ) { 110 | continue; 111 | } 112 | 113 | $files[] = $fileinfo->getFilename(); 114 | } 115 | 116 | return $files; 117 | } 118 | 119 | /** 120 | * Move a file. 121 | * 122 | * @since 0.3.0 123 | * 124 | * @param string $source Absolute path to a file on the local file system. 125 | * @param string $destination Relative destination path; includes the file name. 126 | * @return bool 127 | */ 128 | public function move( string $source, string $destination ): bool { 129 | $filename = $this->get_absolute_path( $destination ); 130 | 131 | if ( ! wp_mkdir_p( \dirname( $filename ) ) ) { 132 | return false; 133 | } 134 | 135 | // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename 136 | if ( ! rename( $source, $filename ) ) { 137 | return false; 138 | } 139 | 140 | return true; 141 | } 142 | 143 | /** 144 | * Send a file for client download. 145 | * 146 | * @since 0.3.0 147 | * 148 | * @param string $file Relative file path. 149 | * @return Response 150 | */ 151 | public function send( string $file ): Response { 152 | $filename = $this->get_absolute_path( $file ); 153 | return Response::for_file( $filename ); 154 | } 155 | 156 | /** 157 | * Retrieve the base storage directory. 158 | * 159 | * @since 0.3.0 160 | * 161 | * @return string 162 | */ 163 | public function get_base_directory(): string { 164 | return $this->base_directory; 165 | } 166 | 167 | /** 168 | * Set the base storage directory. 169 | * 170 | * @since 0.3.0 171 | * 172 | * @param string $directory Absolute path. 173 | * @return $this 174 | */ 175 | public function set_base_directory( string $directory ): Storage { 176 | $this->base_directory = rtrim( $directory, '/' ) . '/'; 177 | return $this; 178 | } 179 | 180 | /** 181 | * Join a relative path with the base storage directory. 182 | * 183 | * @since 0.3.0 184 | * 185 | * @param string $path Relative path. 186 | * @return string 187 | */ 188 | public function get_absolute_path( string $path = '' ): string { 189 | return $this->get_base_directory() . ltrim( $path, '/' ); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/Storage/Storage.php: -------------------------------------------------------------------------------- 1 | 'wordpress-dropin', 30 | 'muplugin' => 'wordpress-muplugin', 31 | 'plugin' => 'wordpress-plugin', 32 | 'theme' => 'wordpress-theme', 33 | ]; 34 | 35 | /** 36 | * Package factory. 37 | * 38 | * @var PackageFactory 39 | */ 40 | protected $factory; 41 | 42 | /** 43 | * Create a Composer package transformer. 44 | * 45 | * @since 0.3.0 46 | * 47 | * @param PackageFactory $factory Package factory. 48 | */ 49 | public function __construct( PackageFactory $factory ) { 50 | $this->factory = $factory; 51 | } 52 | 53 | /** 54 | * Transform a package into a Composer package. 55 | * 56 | * @since 0.3.0 57 | * 58 | * @param Package $package Package. 59 | * @return Package 60 | */ 61 | public function transform( Package $package ) { 62 | $builder = $this->factory->create( 'composer' )->with_package( $package ); 63 | 64 | $vendor = apply_filters( 'satispress_vendor', 'satispress' ); 65 | $name = $this->normalize_package_name( $package->get_slug() ); 66 | $builder->set_name( $vendor . '/' . $name ); 67 | 68 | if ( isset( self::WORDPRESS_TYPES[ $package->get_type() ] ) ) { 69 | $builder->set_type( self::WORDPRESS_TYPES[ $package->get_type() ] ); 70 | } 71 | 72 | return $builder->build(); 73 | } 74 | 75 | /** 76 | * Normalize a package name for packages.json. 77 | * 78 | * @since 0.4.0 79 | * 80 | * @link https://github.com/composer/composer/blob/79af9d45afb6bcaac8b73ae6a8ae24414ddf8b4b/src/Composer/Package/Loader/ValidatingArrayLoader.php#L339-L369 81 | * 82 | * @param string $name Package name. 83 | * @return string 84 | */ 85 | protected function normalize_package_name( $name ) { 86 | $name = strtolower( $name ); 87 | return preg_replace( '/[^a-z0-9_\-\.]+/i', '', $name ); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Transformer/PackageRepositoryTransformer.php: -------------------------------------------------------------------------------- 1 | has_top_level_macosx_directory( $zip ) ) { 38 | throw InvalidPackageArtifact::containsMacOsxDirectory( $filename ); 39 | } 40 | 41 | return true; 42 | } 43 | 44 | /** 45 | * Whether a zip contains a top level __MACOSX directory. 46 | * 47 | * @since 0.7.0 48 | * 49 | * @param PclZip $zip PclZip instance. 50 | * @return bool 51 | */ 52 | protected function has_top_level_macosx_directory( PclZip $zip ): bool { 53 | $directories = []; 54 | 55 | $contents = $zip->listContent(); 56 | foreach ( $contents as $file ) { 57 | if ( '__MACOSX/' === substr( $file['filename'], 0, 9 ) ) { 58 | return true; 59 | } 60 | } 61 | 62 | return false; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Validator/ZipValidator.php: -------------------------------------------------------------------------------- 1 | properties() ) { 38 | throw InvalidPackageArtifact::unreadableZip( $filename ); 39 | } 40 | 41 | return true; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/VersionParser.php: -------------------------------------------------------------------------------- 1 | original_theme_directories = $GLOBALS['wp_theme_directories']; 16 | register_theme_directory( SATISPRESS_TESTS_DIR . '/Fixture/wp-content/themes' ); 17 | delete_site_transient( 'theme_roots' ); 18 | 19 | $this->factory = plugin()->get_container()->get( 'package.factory' ); 20 | } 21 | 22 | public function teardDown() { 23 | delete_site_transient( 'theme_roots' ); 24 | $GLOBALS['wp_theme_directories'] = $this->original_theme_directories; 25 | } 26 | 27 | public function test_get_theme_from_source() { 28 | $package = $this->factory->create( 'theme' ) 29 | ->from_source( 'ovation' ) 30 | ->build(); 31 | 32 | $this->assertInstanceOf( Theme::class, $package ); 33 | 34 | $this->assertSame( 'AudioTheme', $package->get_author() ); 35 | $this->assertSame( 'https://audiotheme.com/', $package->get_author_url() ); 36 | $this->assertSame( get_theme_root() . '/ovation/', $package->get_directory() ); 37 | $this->assertSame( 'https://audiotheme.com/view/ovation/', $package->get_homepage() ); 38 | $this->assertSame( 'Ovation', $package->get_name() ); 39 | $this->assertSame( '1.1.1', $package->get_installed_version() ); 40 | $this->assertSame( 'ovation', $package->get_slug() ); 41 | $this->assertSame( 'theme', $package->get_type() ); 42 | $this->assertTrue( $package->is_installed() ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/phpunit/Integration/Repository/InstalledPluginsTest.php: -------------------------------------------------------------------------------- 1 | get_container()['repository.plugins']; 17 | $package = $repository->first_where( [ 'slug' => 'basic/basic.php' ] ); 18 | 19 | $this->assertInstanceOf( Plugin::class, $package ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/phpunit/Integration/TestCase.php: -------------------------------------------------------------------------------- 1 | directory = SATISPRESS_TESTS_DIR . '/Fixture/wp-content/uploads/satispress/packages/validate'; 16 | 17 | $this->release = $this->getMockBuilder( Release::class ) 18 | ->disableOriginalConstructor() 19 | ->getMock(); 20 | 21 | $this->validator = new HiddenDirectoryValidator(); 22 | } 23 | 24 | public function test_artifact_is_valid_zip() { 25 | $filename = $this->directory . '/valid-zip.zip'; 26 | $result = $this->validator->validate( $filename, $this->release ); 27 | $this->assertTrue( $result ); 28 | } 29 | 30 | public function test_validator_throws_exception_for_invalid_artifact() { 31 | $this->expectException( InvalidPackageArtifact::class ); 32 | $filename = $this->directory . '/invalid-osx-zip.zip'; 33 | $this->validator->validate( $filename, $this->release ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/phpunit/Integration/Validator/ZipValidatorTest.php: -------------------------------------------------------------------------------- 1 | directory = SATISPRESS_TESTS_DIR . '/Fixture/wp-content/uploads/satispress/packages/validate'; 17 | 18 | $this->release = $this->getMockBuilder( Release::class ) 19 | ->disableOriginalConstructor() 20 | ->getMock(); 21 | 22 | $this->validator = new ZipValidator(); 23 | } 24 | 25 | public function test_artifact_is_valid_zip() { 26 | $filename = $this->directory . '/valid-zip.zip'; 27 | $result = $this->validator->validate( $filename, $this->release ); 28 | $this->assertTrue( $result ); 29 | } 30 | 31 | public function test_validator_throws_exception_for_invalid_artifact() { 32 | $this->expectException( InvalidPackageArtifact::class ); 33 | $filename = $this->directory . '/invalid-zip.zip'; 34 | $this->validator->validate( $filename, $this->release ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/phpunit/Unit/PackageType/PackageBuilderTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder( ReleaseManager::class ) 17 | ->disableOriginalConstructor() 18 | ->getMock(); 19 | 20 | $package = new class extends BasePackage { 21 | public function __get( $name ) { 22 | return $this->$name; 23 | } 24 | }; 25 | 26 | $this->builder = new PackageBuilder( $package, $manager ); 27 | } 28 | 29 | public function test_implements_package_interface() { 30 | $package = $this->builder->build(); 31 | 32 | $this->assertInstanceOf( Package::class, $package ); 33 | } 34 | 35 | public function test_author() { 36 | $expected = 'Cedaro'; 37 | $package = $this->builder->set_author( $expected )->build(); 38 | 39 | $this->assertSame( $expected, $package->author ); 40 | } 41 | 42 | public function test_author_url() { 43 | $expected = 'https://www.cedaro.com/'; 44 | $package = $this->builder->set_author_url( $expected )->build(); 45 | 46 | $this->assertSame( $expected, $package->author_url ); 47 | } 48 | 49 | public function test_description() { 50 | $expected = 'A package description.'; 51 | $package = $this->builder->set_description( $expected )->build(); 52 | 53 | $this->assertSame( $expected, $package->description ); 54 | } 55 | 56 | public function test_directory() { 57 | $expected = 'directory'; 58 | $package = $this->builder->set_directory( $expected )->build(); 59 | 60 | $this->assertSame( $expected . '/', $package->directory ); 61 | } 62 | 63 | public function test_homepage() { 64 | $expected = 'https://www.cedaro.com/'; 65 | $package = $this->builder->set_homepage( $expected )->build(); 66 | 67 | $this->assertSame( $expected, $package->homepage ); 68 | } 69 | 70 | public function test_is_installed() { 71 | $package = $this->builder->build(); 72 | $this->assertFalse( $package->is_installed ); 73 | 74 | $package = $this->builder->set_installed( true )->build(); 75 | $this->assertTrue( $package->is_installed ); 76 | } 77 | 78 | public function test_installed_version() { 79 | $expected = '1.0.0'; 80 | $package = $this->builder->set_installed( true )->set_installed_version( $expected )->build(); 81 | 82 | $this->assertSame( $expected, $package->installed_version ); 83 | } 84 | 85 | public function test_name() { 86 | $expected = 'SatisPress'; 87 | $package = $this->builder->set_name( $expected )->build(); 88 | 89 | $this->assertSame( $expected, $package->name ); 90 | } 91 | 92 | public function test_slug() { 93 | $expected = 'satispress'; 94 | $package = $this->builder->set_slug( $expected )->build(); 95 | 96 | $this->assertSame( $expected, $package->slug ); 97 | } 98 | 99 | public function test_type() { 100 | $expected = 'plugin'; 101 | $package = $this->builder->set_type( $expected )->build(); 102 | 103 | $this->assertSame( $expected, $package->type ); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /tests/phpunit/Unit/PackageType/PackageReleasesTest.php: -------------------------------------------------------------------------------- 1 | builder = new PackageBuilder( $package, $manager ); 28 | } 29 | 30 | public function test_package_has_no_releases() { 31 | $package = $this->builder->build(); 32 | $this->assertFalse( $package->has_releases() ); 33 | } 34 | 35 | public function test_package_has_releases() { 36 | $package = $this->builder->add_release( '1.0.0' )->build(); 37 | $this->assertTrue( $package->has_releases() ); 38 | } 39 | 40 | public function test_get_release_by_version() { 41 | $version = '1.0.0'; 42 | $package = $this->builder->add_release( $version )->build(); 43 | 44 | $this->assertSame( 1, count( $package->get_releases() ) ); 45 | 46 | $release = $package->get_release( $version ); 47 | $this->assertInstanceOf( Release::class, $release ); 48 | $this->assertSame( $version, $release->get_version() ); 49 | } 50 | 51 | public function test_get_installed_release() { 52 | $installed_version = '0.4.0'; 53 | $latest_version = '1.0.0'; 54 | 55 | $package = $this->builder 56 | ->set_installed( true ) 57 | ->set_installed_version( $installed_version ) 58 | ->add_release( $installed_version ) 59 | ->add_release( $latest_version ) 60 | ->build(); 61 | 62 | $release = $package->get_installed_release(); 63 | $this->assertInstanceOf( Release::class, $release ); 64 | $this->assertTrue( $package->is_installed_release( $release ) ); 65 | 66 | $release = $package->get_release( $latest_version ); 67 | $this->assertFalse( $package->is_installed_release( $release ) ); 68 | } 69 | 70 | public function test_get_latest_release() { 71 | $version = '0.4.0'; 72 | $package = $this->builder 73 | ->add_release( '0.3.2' ) 74 | ->add_release( $version ) 75 | ->add_release( '0.3.0' ) 76 | ->build(); 77 | 78 | $release = $package->get_latest_release(); 79 | 80 | $this->assertInstanceOf( Release::class, $release ); 81 | $this->assertSame( $version, $release->get_version() ); 82 | } 83 | 84 | public function test_is_update_available() { 85 | $installed_version = '0.4.0'; 86 | $latest_version = '1.0.0'; 87 | 88 | $package = $this->builder 89 | ->set_installed( true ) 90 | ->set_installed_version( $installed_version ) 91 | ->add_release( $installed_version ) 92 | ->add_release( $latest_version ) 93 | ->build(); 94 | 95 | $this->assertSame( $installed_version, $package->get_installed_version() ); 96 | $this->assertSame( $latest_version, $package->get_latest_version() ); 97 | $this->assertTrue( $package->is_update_available() ); 98 | } 99 | 100 | public function test_get_latest_release_throws_exception_when_there_are_no_releases() { 101 | $this->expectException( InvalidReleaseVersion::class ); 102 | 103 | $package = $this->builder->build(); 104 | $package->get_latest_release(); 105 | } 106 | 107 | public function test_get_unknown_release_throws_exception() { 108 | $this->expectException( InvalidReleaseVersion::class ); 109 | 110 | $package = $this->builder->build(); 111 | $package->get_release( '0.4.0' ); 112 | } 113 | 114 | public function test_get_not_installed_release_throws_exception() { 115 | $this->expectException( PackageNotInstalled::class ); 116 | 117 | $package = $this->builder->build(); 118 | $package->get_installed_release(); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /tests/phpunit/Unit/PackageType/PackageTest.php: -------------------------------------------------------------------------------- 1 | package = new class extends BasePackage { 18 | public function __set( $name, $value ) { 19 | $this->$name = $value; 20 | } 21 | }; 22 | } 23 | 24 | public function test_implements_package_interface() { 25 | $this->assertInstanceOf( Package::class, $this->package ); 26 | } 27 | 28 | public function test_author() { 29 | $expected = 'Cedaro'; 30 | $this->package->author = $expected; 31 | 32 | $this->assertSame( $expected, $this->package->get_author() ); 33 | } 34 | 35 | public function test_author_url() { 36 | $expected = 'https://www.cedaro.com/'; 37 | $this->package->author_url = $expected; 38 | 39 | $this->assertSame( $expected, $this->package->get_author_url() ); 40 | } 41 | 42 | public function test_description() { 43 | $expected = 'A package description.'; 44 | $this->package->description = $expected; 45 | 46 | $this->assertSame( $expected, $this->package->get_description() ); 47 | } 48 | 49 | public function test_directory() { 50 | $expected = __DIR__ . '/'; 51 | $this->package->directory = $expected; 52 | 53 | $this->assertSame( $expected, $this->package->get_directory() ); 54 | } 55 | 56 | public function test_homepage() { 57 | $expected = 'https://www.cedaro.com/'; 58 | $this->package->homepage = $expected; 59 | 60 | $this->assertSame( $expected, $this->package->get_homepage() ); 61 | } 62 | 63 | public function test_is_installed() { 64 | $this->assertFalse( $this->package->is_installed() ); 65 | 66 | $this->package->is_installed = true; 67 | $this->assertTrue( $this->package->is_installed() ); 68 | } 69 | 70 | public function test_installed_version() { 71 | $expected = '1.0.0'; 72 | $this->package->is_installed = true; 73 | $this->package->installed_version = $expected; 74 | 75 | $this->assertSame( $expected, $this->package->get_installed_version() ); 76 | } 77 | 78 | public function test_get_installed_version_throws_exception_when_plugin_not_installed() { 79 | $this->expectException( PackageNotInstalled::class ); 80 | 81 | $this->package->installed_version = '1.0.0'; 82 | $this->package->get_installed_version(); 83 | } 84 | 85 | public function test_name() { 86 | $expected = 'SatisPress'; 87 | $this->package->name = $expected; 88 | 89 | $this->assertSame( $expected, $this->package->get_name() ); 90 | } 91 | 92 | public function test_slug() { 93 | $expected = 'satispress'; 94 | $this->package->slug = $expected; 95 | 96 | $this->assertSame( $expected, $this->package->get_slug() ); 97 | } 98 | 99 | public function test_type() { 100 | $expected = 'plugin'; 101 | $this->package->type = $expected; 102 | 103 | $this->assertSame( $expected, $this->package->get_type() ); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /tests/phpunit/Unit/PackageType/PluginReleasesTest.php: -------------------------------------------------------------------------------- 1 | justReturn( $this->get_update_transient() ); 21 | 22 | $archiver = new Archiver( new NullLogger() ); 23 | $storage = new LocalStorage( SATISPRESS_TESTS_DIR . '/Fixture/wp-content/uploads/satispress/packages' ); 24 | $manager = new ReleaseManager( $storage, $archiver ); 25 | $package = new Plugin(); 26 | 27 | $this->builder = ( new PluginBuilder( $package, $manager ) ) 28 | ->set_basename( 'basic/basic.php' ) 29 | ->set_slug( 'basic' ); 30 | } 31 | 32 | public function test_get_cached_releases_from_storage() { 33 | $package = $this->builder 34 | ->add_cached_releases() 35 | ->build(); 36 | 37 | $this->assertInstanceOf( Release::class, $package->get_release( '1.0.0' ) ); 38 | } 39 | 40 | public function test_get_cached_releases_includes_installed_version() { 41 | $package = $this->builder 42 | ->set_installed( true ) 43 | ->set_installed_version( '1.3.1' ) 44 | ->add_cached_releases() 45 | ->build(); 46 | 47 | $this->assertSame( '1.3.1', $package->get_installed_release()->get_version() ); 48 | } 49 | 50 | public function test_get_cached_releases_includes_pending_update() { 51 | $package = $this->builder 52 | ->set_installed( true ) 53 | ->set_installed_version( '1.3.1' ) 54 | ->add_cached_releases() 55 | ->build(); 56 | 57 | $this->assertSame( '2.0.0', $package->get_latest_release()->get_version() ); 58 | } 59 | 60 | protected function get_update_transient() { 61 | return (object) [ 62 | 'response' => [ 63 | 'basic/basic.php' => (object) [ 64 | 'slug' => 'basic', 65 | 'plugin' => 'basic/basic.php', 66 | 'new_version' => '2.0.0', 67 | 'package' => 'https://example.org/download/basic/2.0.0.zip', 68 | ], 69 | ], 70 | ]; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/phpunit/Unit/PackageType/PluginTest.php: -------------------------------------------------------------------------------- 1 | justReturn( $this->get_plugin_data() ); 21 | Functions\when( 'get_site_transient' )->justReturn( new \stdClass() ); 22 | 23 | $archiver = new Archiver( new NullLogger() ); 24 | $storage = new LocalStorage( SATISPRESS_TESTS_DIR . '/Fixture/wp-content/uploads/satispress/packages' ); 25 | $manager = new ReleaseManager( $storage, $archiver ); 26 | $package = new Plugin(); 27 | 28 | $this->builder = new PluginBuilder( $package, $manager ); 29 | } 30 | 31 | public function test_get_plugin_from_source() { 32 | $package = $this->builder 33 | ->from_source( 'basic/basic.php' ) 34 | ->build(); 35 | 36 | $this->assertInstanceOf( Plugin::class, $package ); 37 | 38 | $this->assertSame( 'Basic, Inc.', $package->get_author() ); 39 | $this->assertSame( 'https://example.com/', $package->get_author_url() ); 40 | $this->assertSame( 'basic/basic.php', $package->get_basename() ); 41 | $this->assertSame( WP_PLUGIN_DIR . '/basic/', $package->get_directory() ); 42 | $this->assertSame( 'https://example.com/plugin/basic/', $package->get_homepage() ); 43 | $this->assertSame( 'Basic Plugin', $package->get_name() ); 44 | $this->assertSame( '1.3.1', $package->get_installed_version() ); 45 | $this->assertSame( 'basic', $package->get_slug() ); 46 | $this->assertSame( 'plugin', $package->get_type() ); 47 | $this->assertTrue( $package->is_installed() ); 48 | } 49 | 50 | public function test_is_single_file_plugin() { 51 | $package = $this->builder->from_source( 'basic/basic.php' )->build(); 52 | $this->assertFalse( $package->is_single_file() ); 53 | 54 | $package = $this->builder->from_source( 'hello.php' )->build(); 55 | $this->assertTrue( $package->is_single_file() ); 56 | } 57 | 58 | public function test_get_files_for_single_file_plugin() { 59 | $package = $this->builder->from_source( 'hello.php' )->build(); 60 | $this->assertSame( 1, count( $package->get_files() ) ); 61 | } 62 | 63 | protected function get_plugin_data() { 64 | return [ 65 | 'AuthorName' => 'Basic, Inc.', 66 | 'AuthorURI' => 'https://example.com/', 67 | 'PluginURI' => 'https://example.com/plugin/basic/', 68 | 'Name' => 'Basic Plugin', 69 | 'Description' => '', 70 | 'Version' => '1.3.1', 71 | ]; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tests/phpunit/Unit/TestCase.php: -------------------------------------------------------------------------------- 1 | getMockBuilder( ReleaseManager::class ) 17 | ->disableOriginalConstructor() 18 | ->getMock(); 19 | 20 | $factory = new PackageFactory( $manager ); 21 | 22 | $this->package = $factory->create( 'plugin' ) 23 | ->set_slug( 'AcmeCode' ) 24 | ->build(); 25 | 26 | $this->transformer = new ComposerPackageTransformer( $factory ); 27 | } 28 | 29 | public function test_package_name_is_lowercased() { 30 | $package = $this->transformer->transform( $this->package ); 31 | $this->assertSame( 'satispress/acmecode', $package->get_name() ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/phpunit/bootstrap.php: -------------------------------------------------------------------------------- 1 | [ 'satispress/satispress.php' ], 24 | 'timezone_string' => 'America/Los_Angeles', 25 | ]; 26 | 27 | $suite->addFilter( 'muplugins_loaded', function() { 28 | require dirname( __DIR__, 2 ) . '/satispress.php'; 29 | } ); 30 | 31 | $suite->addFilter( 'satispress_compose', function( $plugin, $container ) { 32 | $container['logger'] = new NullLogger(); 33 | $container['storage.working_directory'] = SATISPRESS_TESTS_DIR . '/Fixture/wp-content/uploads/satispress/'; 34 | }, 10, 2 ); 35 | 36 | $suite->bootstrap(); 37 | -------------------------------------------------------------------------------- /tests/phpunit/patchwork.json: -------------------------------------------------------------------------------- 1 | { 2 | "redefinable-internals": [ 3 | "header" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /views/screen-settings.php: -------------------------------------------------------------------------------- 1 | 15 | 16 |
    17 |
    18 |

    19 | 35 | 36 | $tab_data ) { 38 | if ( ! current_user_can( $tab_data['capability'] ) ) { 39 | continue; 40 | } 41 | 42 | printf( 43 | '
    ', 44 | esc_attr( $tab_id ), 45 | $active_tab === $tab_id ? ' is-active' : '' 46 | ); 47 | 48 | require $this->plugin->get_path( "views/tabs/{$tab_id}.php" ); 49 | 50 | echo '
    '; 51 | } 52 | ?> 53 |
    54 | 55 |
    56 |
    57 | -------------------------------------------------------------------------------- /views/tabs/access.php: -------------------------------------------------------------------------------- 1 | 15 |

    16 | 17 |

    18 | 19 |

    20 | satispress */ 22 | printf( esc_html__( 'The password for all API keys is %s.', 'satispress' ), 'satispress' ); 23 | ?> 24 |

    25 | 26 |
    27 | 28 |

    29 | 30 |

    31 | -------------------------------------------------------------------------------- /views/tabs/composer.php: -------------------------------------------------------------------------------- 1 | 15 | 16 |

    17 | 18 | 19 |

    20 |

    21 | [] ]; 24 | printf( 25 | /* translators: 1: repositories, 2: composer.json */ 26 | esc_html__( 'Add it to the %1$s list in your %2$s:', 'satispress' ), 27 | 'repositories', 28 | 'composer.json' 29 | ); 30 | ?> 31 |

    32 | 33 |
    {
    34 | 	"repositories": {
    35 | 		"satispress": {
    36 | 			"type": "composer",
    37 | 			"url": " true ] ) ); ?>"
    38 | 		}
    39 | 	}
    40 | }
    41 | 42 | [] ]; 45 | printf( 46 | /* translators: 1: config */ 47 | esc_html__( 'Or run the %1$s command:', 'satispress' ), 48 | 'config' 49 | ); 50 | ?> 51 | 52 |

    53 | 60 |

    61 | -------------------------------------------------------------------------------- /views/tabs/repository.php: -------------------------------------------------------------------------------- 1 | 15 | 16 |
    17 | -------------------------------------------------------------------------------- /views/tabs/settings.php: -------------------------------------------------------------------------------- 1 | 15 | 16 |
    17 | 18 | 19 | 20 |
    21 | --------------------------------------------------------------------------------