├── public └── install │ ├── api │ ├── .gitignore │ ├── src │ │ ├── Exception │ │ │ └── SSLValidationException.php │ │ └── Api.php │ └── composer.json │ ├── favicon.ico │ └── api.php ├── .vscode └── extensions.json ├── src ├── assets │ ├── scss │ │ ├── _custom.scss │ │ ├── _steps.scss │ │ ├── base.scss │ │ ├── _transitions.scss │ │ ├── _bars.scss │ │ ├── _typography.scss │ │ ├── _variables.scss │ │ └── _forms.scss │ └── img │ │ ├── background.jpg │ │ ├── circle-logo.png │ │ └── sidebar-logo.png ├── store │ ├── index.js │ └── steps.js ├── main.js ├── mixins │ └── step.js ├── components │ ├── Tab.vue │ ├── steps │ │ ├── Introduction.vue │ │ ├── License.vue │ │ ├── Complete.vue │ │ ├── FinalChecks.vue │ │ ├── Installation.vue │ │ ├── Checks.vue │ │ └── Configuration.vue │ ├── Tabs.vue │ ├── TabNav.vue │ ├── Sidebar.vue │ ├── Check.vue │ └── Click.vue ├── plugins │ └── api.js └── Installer.vue ├── .env.local.example ├── .github ├── web-installer.jpg └── workflows │ └── release.yaml ├── .env.production ├── .editorconfig ├── .gitignore ├── install.html ├── package.json ├── .eslintrc.js ├── LICENSE ├── vite.config.js └── README.md /public/install/api/.gitignore: -------------------------------------------------------------------------------- 1 | composer.phar 2 | vendor/ -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar"] 3 | } 4 | -------------------------------------------------------------------------------- /src/assets/scss/_custom.scss: -------------------------------------------------------------------------------- 1 | .columns + .columns { 2 | margin-top: $layout-spacing-sm; 3 | } 4 | -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | # Web base URL to the /install/api.php script. 2 | VITE_APP_INSTALL_URL="http://127.0.0.1:8081" -------------------------------------------------------------------------------- /.github/web-installer.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wintercms/web-installer/HEAD/.github/web-installer.jpg -------------------------------------------------------------------------------- /public/install/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wintercms/web-installer/HEAD/public/install/favicon.ico -------------------------------------------------------------------------------- /src/assets/img/background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wintercms/web-installer/HEAD/src/assets/img/background.jpg -------------------------------------------------------------------------------- /src/assets/img/circle-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wintercms/web-installer/HEAD/src/assets/img/circle-logo.png -------------------------------------------------------------------------------- /src/assets/img/sidebar-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wintercms/web-installer/HEAD/src/assets/img/sidebar-logo.png -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | # Web base URL to the /install/api.php script. On production, this should remain as is. 2 | VITE_APP_INSTALL_URL="./install" -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | import steps from './steps'; 4 | 5 | Vue.use(Vuex); 6 | 7 | export default new Vuex.Store({ 8 | modules: { 9 | steps, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /public/install/api.php: -------------------------------------------------------------------------------- 1 | request(); -------------------------------------------------------------------------------- /public/install/api/src/Exception/SSLValidationException.php: -------------------------------------------------------------------------------- 1 | h(Installer), 14 | }).$mount('#app'); 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue,scss}] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | max_line_length = 100 8 | 9 | [*.{php}] 10 | indent_style = space 11 | indent_size = 4 12 | end_of_line = lf 13 | trim_trailing_whitespace = true 14 | insert_final_newline = true 15 | max_line_length = 120 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Installer files 27 | public/.temp.sqlite 28 | public/install.log 29 | public/.ignore-ssl -------------------------------------------------------------------------------- /src/assets/scss/_steps.scss: -------------------------------------------------------------------------------- 1 | .step { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | height: 100%; 7 | display: flex; 8 | flex-direction: column; 9 | 10 | .step-content { 11 | flex-grow: 1; 12 | flex-shrink: 1; 13 | padding: $layout-spacing $layout-spacing-lg; 14 | } 15 | 16 | .step-actions { 17 | flex-grow: 0; 18 | flex-shrink: 0; 19 | height: 100px; 20 | 21 | line-height: 100px; 22 | text-align: center; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/mixins/step.js: -------------------------------------------------------------------------------- 1 | export default { 2 | computed: { 3 | isActive() { 4 | return this.$store.getters['steps/isActive'](this.stepId); 5 | }, 6 | }, 7 | data() { 8 | return { 9 | stepId: 'step', 10 | stepName: 'Step', 11 | }; 12 | }, 13 | created() { 14 | this.$store.dispatch('steps/add', { 15 | id: this.stepId, 16 | name: this.stepName, 17 | }); 18 | }, 19 | destroyed() { 20 | this.$store.dispatch('steps/remove', { 21 | id: this.stepId, 22 | }); 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/assets/scss/base.scss: -------------------------------------------------------------------------------- 1 | // Variables and mixins 2 | @import "spectre.css/src/mixins"; 3 | 4 | // Reset and dependencies 5 | @import "spectre.css/src/normalize"; 6 | @import "spectre.css/src/base"; 7 | 8 | // Elements 9 | @import "typography"; 10 | @import "forms"; 11 | @import "spectre.css/src/labels"; 12 | @import "spectre.css/src/codes"; 13 | @import "bars"; 14 | 15 | // Layout 16 | @import "spectre.css/src/layout"; 17 | @import "steps"; 18 | 19 | // Utilities 20 | @import "transitions"; 21 | 22 | // Custom styling 23 | @import "custom"; 24 | -------------------------------------------------------------------------------- /src/assets/scss/_transitions.scss: -------------------------------------------------------------------------------- 1 | .fade-enter-active, .fade-leave-active { 2 | transition: opacity 250ms; 3 | } 4 | 5 | .fade-enter-active { 6 | transition-delay: 250ms; 7 | } 8 | 9 | .fade-enter, .fade-leave-to { 10 | opacity: 0; 11 | } 12 | 13 | @keyframes showTick { 14 | 0% { 15 | top: 120%; 16 | opacity: 0; 17 | } 18 | 19 | 100% { 20 | top: 50%; 21 | opacity: 1; 22 | } 23 | } 24 | 25 | .install-step-item { 26 | transition: all 500ms ease; 27 | } 28 | 29 | .install-step-enter, .install-step-leave-to { 30 | opacity: 0; 31 | } 32 | -------------------------------------------------------------------------------- /install.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Winter CMS Installer 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/install/api/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wintercms/web-installer", 3 | "description": "Web-based installer for Winter CMS", 4 | "type": "project", 5 | "require": { 6 | "winter/packager": "^0.2.2", 7 | "winter/laravel-config-writer": "^1.0.1", 8 | "doctrine/dbal": "^3.1.4", 9 | "illuminate/database": "^9.20.0", 10 | "monolog/monolog": "^2.7.0" 11 | }, 12 | "autoload": { 13 | "psr-4": { 14 | "Winter\\Installer\\": "src" 15 | } 16 | }, 17 | "config": { 18 | "platform": { 19 | "php": "8.0.2" 20 | } 21 | }, 22 | "license": "MIT", 23 | "minimum-stability": "dev", 24 | "prefer-stable": true 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-installer-vite", 3 | "private": true, 4 | "version": "1.0.1", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "npm run dellog && npm run delbuild && vite build", 8 | "preview": "vite preview", 9 | "dellog": "npx rimraf ./public/install.log", 10 | "delbuild": "npx rimraf ./dist", 11 | "lint": "eslint --ext .js,.vue --ignore-path .gitignore src" 12 | }, 13 | "dependencies": { 14 | "vue": "^2.6.14", 15 | "spectre.css": "^0.5.9", 16 | "vee-validate": "^3.4.13", 17 | "vuex": "^3.4.0" 18 | }, 19 | "devDependencies": { 20 | "eslint": "^8.10.0", 21 | "eslint-import-resolver-alias": "^1.1.2", 22 | "eslint-plugin-import": "^2.25.4", 23 | "eslint-plugin-vue": "^8.5.0", 24 | "vite-plugin-vue2": "^1.9.3", 25 | "vite": "^2.8.0", 26 | "@vue/eslint-config-airbnb": "^6.0.0", 27 | "vue-template-compiler": "^2.6.14", 28 | "sass": "^1.49.9", 29 | "sass-loader": "^12.6.0" 30 | } 31 | } -------------------------------------------------------------------------------- /src/components/Tab.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 56 | -------------------------------------------------------------------------------- /src/assets/scss/_bars.scss: -------------------------------------------------------------------------------- 1 | // Bars 2 | .bar { 3 | background: darken($gray-color-light, 5%); 4 | border-radius: $border-radius; 5 | display: flex; 6 | flex-wrap: nowrap; 7 | height: $unit-4; 8 | width: 100%; 9 | margin-bottom: $layout-spacing; 10 | 11 | &.bar-sm { 12 | height: $unit-1; 13 | } 14 | 15 | // TODO: attr() support 16 | .bar-item { 17 | background: $primary-color; 18 | color: $light-color; 19 | display: block; 20 | font-size: $font-size-sm; 21 | flex-shrink: 0; 22 | line-height: $unit-4; 23 | height: 100%; 24 | position: relative; 25 | text-align: center; 26 | width: 0; 27 | transition: width 350ms ease, background-color 350ms ease; 28 | 29 | &:first-child { 30 | border-bottom-left-radius: $border-radius; 31 | border-top-left-radius: $border-radius; 32 | } 33 | &:last-child { 34 | border-bottom-right-radius: $border-radius; 35 | border-top-right-radius: $border-radius; 36 | flex-shrink: 1; 37 | } 38 | } 39 | 40 | &.bar-error .bar-item { 41 | background: $error-color; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | 'plugin:vue/essential', 8 | '@vue/airbnb', 9 | ], 10 | rules: { 11 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 12 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 13 | 'prefer-template': 'off', 14 | 'prefer-destructuring': ['error', { object: true, array: false }], 15 | 'no-param-reassign': [ 16 | 'error', 17 | { props: false }, 18 | ], 19 | 'no-plusplus': [ 20 | 2, 21 | { 22 | allowForLoopAfterthoughts: true, 23 | }, 24 | ], 25 | 'vue/no-parsing-error': ['error', { 26 | 'invalid-first-character-of-tag-name': false, 27 | }], 28 | 'vue/multi-word-component-names': 'off', 29 | 'vuejs-accessibility/label-has-for': 'off', 30 | 'vuejs-accessibility/tabindex-no-positive': 'off', 31 | }, 32 | settings: { 33 | 'import/resolver': { 34 | alias: { 35 | map: [ 36 | ['@', './src'], 37 | ], 38 | }, 39 | }, 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Winter CMS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import { createVuePlugin } from 'vite-plugin-vue2'; 3 | import path from 'path'; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [createVuePlugin()], 8 | base: './', 9 | resolve: { 10 | alias: { 11 | '@': path.resolve(__dirname, './src'), 12 | }, 13 | }, 14 | css: { 15 | preprocessorOptions: { 16 | scss: { 17 | additionalData: (content, loaderContext) => { 18 | const relativePath = path.relative(__dirname, loaderContext); 19 | 20 | if (relativePath === 'src/assets/scss/_variables.scss') { 21 | return content; 22 | } 23 | 24 | return `@import "@/assets/scss/variables.scss";\n${content}`; 25 | }, 26 | }, 27 | }, 28 | }, 29 | server: { 30 | open: '/install.html', 31 | }, 32 | build: { 33 | assetsDir: 'install/assets', 34 | emptyOutDir: true, 35 | rollupOptions: { 36 | input: { 37 | main: path.resolve(__dirname, 'install.html'), 38 | }, 39 | output: { 40 | sourcemap: false, 41 | }, 42 | makeAbsoluteExternalsRelative: true, 43 | }, 44 | }, 45 | }); 46 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - 'v*' 5 | 6 | name: Publish release 7 | 8 | jobs: 9 | publish: 10 | name: Publish release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | 16 | - name: Setup PHP 17 | uses: shivammathur/setup-php@v2 18 | with: 19 | php-version: '7.2' 20 | tools: composer 21 | 22 | - name: Setup NodeJS 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: '14' 26 | 27 | - name: Install Composer dependencies 28 | working-directory: ./public/install/api 29 | run: composer install --no-progress 30 | 31 | - name: Install Node.JS dependencies 32 | run: npm install 33 | 34 | - name: Create build 35 | run: npm run build 36 | 37 | - name: Create ZIP file 38 | working-directory: ./dist 39 | run: zip install.zip -r install install.html 40 | 41 | - name: Create release 42 | id: create_release 43 | uses: actions/create-release@v1 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | with: 47 | tag_name: ${{ github.ref }} 48 | release_name: ${{ github.ref }} 49 | draft: true 50 | prerelease: false 51 | 52 | - name: Upload Release Asset 53 | id: upload-release-asset 54 | uses: actions/upload-release-asset@v1 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | with: 58 | upload_url: ${{ steps.create_release.outputs.upload_url }} 59 | asset_path: ./dist/install.zip 60 | asset_name: install.zip 61 | asset_content_type: application/zip -------------------------------------------------------------------------------- /src/components/steps/Introduction.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 60 | 61 | 87 | -------------------------------------------------------------------------------- /src/assets/scss/_typography.scss: -------------------------------------------------------------------------------- 1 | // Typography 2 | // Headings 3 | h1, 4 | h2, 5 | h3, 6 | h4, 7 | h5, 8 | h6 { 9 | color: darken($dark-color, 12%); 10 | font-family: $heading-font-family; 11 | font-weight: $heading-font-weight; 12 | line-height: 1.2; 13 | margin-bottom: .5em; 14 | margin-top: 0; 15 | } 16 | .h1, 17 | .h2, 18 | .h3, 19 | .h4, 20 | .h5, 21 | .h6 { 22 | font-family: $heading-font-family; 23 | font-weight: $heading-font-weight; 24 | } 25 | h1, 26 | .h1 { 27 | font-size: 2rem; 28 | } 29 | h2, 30 | .h2 { 31 | font-size: 1.6rem; 32 | } 33 | h3, 34 | .h3 { 35 | font-size: 1.4rem; 36 | } 37 | h4, 38 | .h4 { 39 | font-size: 1.2rem; 40 | } 41 | h5, 42 | .h5 { 43 | font-size: 1rem; 44 | } 45 | h6, 46 | .h6 { 47 | font-size: .8rem; 48 | } 49 | 50 | // Paragraphs 51 | p { 52 | margin: 0 0 $line-height; 53 | } 54 | 55 | // Semantic text elements 56 | a, 57 | ins, 58 | u { 59 | text-decoration-skip: ink edges; 60 | } 61 | 62 | abbr[title] { 63 | border-bottom: $border-width dotted; 64 | cursor: help; 65 | text-decoration: none; 66 | } 67 | 68 | kbd { 69 | @include label-base(); 70 | @include label-variant($light-color, $dark-color); 71 | font-size: $font-size-sm; 72 | } 73 | 74 | mark { 75 | @include label-variant($body-font-color, $highlight-color); 76 | border-bottom: $unit-o solid darken($highlight-color, 15%); 77 | border-radius: $border-radius; 78 | padding: $unit-o $unit-h 0; 79 | } 80 | 81 | // Blockquote 82 | blockquote { 83 | border-left: $border-width-lg solid $border-color; 84 | margin-left: 0; 85 | padding: $unit-2 $unit-4; 86 | 87 | p:last-child { 88 | margin-bottom: 0; 89 | } 90 | } 91 | 92 | // Lists 93 | ul, 94 | ol { 95 | margin: $unit-4 0 $unit-4 $unit-4; 96 | padding: 0; 97 | 98 | ul, 99 | ol { 100 | margin: $unit-4 0 $unit-4 $unit-4; 101 | } 102 | 103 | li { 104 | margin-top: $unit-2; 105 | } 106 | } 107 | 108 | ul { 109 | list-style: disc inside; 110 | 111 | ul { 112 | list-style-type: circle; 113 | } 114 | } 115 | 116 | ol { 117 | list-style: decimal inside; 118 | 119 | ol { 120 | list-style-type: lower-alpha; 121 | } 122 | } 123 | 124 | dl { 125 | dt { 126 | font-weight: bold; 127 | } 128 | dd { 129 | margin: $unit-2 0 $unit-4 0; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/components/Tabs.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 94 | 95 | 100 | -------------------------------------------------------------------------------- /src/components/TabNav.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 60 | 61 | 107 | -------------------------------------------------------------------------------- /src/components/steps/License.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 76 | 77 | 94 | -------------------------------------------------------------------------------- /src/store/steps.js: -------------------------------------------------------------------------------- 1 | export default { 2 | namespaced: true, 3 | state: { 4 | steps: [], 5 | }, 6 | mutations: { 7 | add(state, data) { 8 | state.steps.push(data); 9 | }, 10 | remove(state, data) { 11 | state.steps.splice(data.key, 1); 12 | }, 13 | changeStatus(state, data) { 14 | state.steps[data.key].status = data.status; 15 | }, 16 | setActive(state, data) { 17 | state.steps.forEach((step) => { 18 | step.active = false; 19 | }); 20 | 21 | state.steps[data.key].active = true; 22 | }, 23 | }, 24 | getters: { 25 | getStepById: (state) => (id) => state.steps.findIndex((step) => step.id === id), 26 | isLocked: (state) => (id) => { 27 | if (state.steps.length === 0) { 28 | return false; 29 | } 30 | if (!state.steps.find((step) => step.id === id)) { 31 | return false; 32 | } 33 | 34 | return (state.steps.find((step) => step.id === id).status === 'locked'); 35 | }, 36 | isActive: (state) => (id) => { 37 | if (state.steps.length === 0) { 38 | return false; 39 | } 40 | if (!state.steps.find((step) => step.id === id)) { 41 | return false; 42 | } 43 | 44 | return state.steps.find((step) => step.id === id).active; 45 | }, 46 | }, 47 | actions: { 48 | add({ commit }, data) { 49 | commit('add', { 50 | id: data.id, 51 | name: data.name, 52 | status: 'locked', 53 | active: false, 54 | }); 55 | }, 56 | remove({ getters, commit }, data) { 57 | if (data.key) { 58 | commit('remove', { 59 | key: data.key, 60 | }); 61 | return; 62 | } 63 | 64 | commit('remove', { 65 | key: getters.getStepById(data.id), 66 | }); 67 | }, 68 | goTo({ getters, commit }, data) { 69 | if (data.key) { 70 | if (getters.isLocked(data.key)) { 71 | commit('changeStatus', { 72 | key: data.key, 73 | status: 'pending', 74 | }); 75 | } 76 | 77 | commit('setActive', { 78 | key: data.key, 79 | }); 80 | return; 81 | } 82 | 83 | if (getters.isLocked(data.id)) { 84 | commit('changeStatus', { 85 | key: getters.getStepById(data.id), 86 | status: 'pending', 87 | }); 88 | } 89 | 90 | commit('setActive', { 91 | key: getters.getStepById(data.id), 92 | }); 93 | }, 94 | setStatus({ getters, commit }, data) { 95 | if (data.key) { 96 | commit('changeStatus', { 97 | key: data.key, 98 | status: data.status, 99 | }); 100 | return; 101 | } 102 | 103 | commit('changeStatus', { 104 | key: getters.getStepById(data.id), 105 | status: data.status, 106 | }); 107 | }, 108 | }, 109 | }; 110 | -------------------------------------------------------------------------------- /src/plugins/api.js: -------------------------------------------------------------------------------- 1 | export default { 2 | install(Vue) { 3 | Vue.prototype.$api = (method, endpoint, data) => { 4 | // Validate arguments 5 | if (typeof method !== 'string' || ['GET', 'POST'].indexOf(method) === -1) { 6 | throw new Error('Invalid method for API call, must be one of: GET, POST'); 7 | } 8 | if (typeof endpoint !== 'string') { 9 | throw new Error('Endpoint must be provided as a string'); 10 | } 11 | 12 | // Allow for API URL override 13 | let fullUrl = 'api.php'; 14 | 15 | if (import.meta.env.VITE_APP_INSTALL_URL) { 16 | const baseUrl = import.meta.env.VITE_APP_INSTALL_URL; 17 | 18 | if (fullUrl.substr(-1, 1) !== '/') { 19 | fullUrl = `${baseUrl}/${fullUrl}`; 20 | } else { 21 | fullUrl = `${baseUrl}${fullUrl}`; 22 | } 23 | } 24 | 25 | // Format provided data for either GET or POST 26 | let postBody = null; 27 | 28 | if (method === 'GET') { 29 | fullUrl = `${fullUrl}?endpoint=${endpoint}`; 30 | 31 | if (data && !Array.isArray(data)) { 32 | throw new Error('Data must be provided as an array'); 33 | } 34 | 35 | if (data && data.length) { 36 | const dataUrl = data.join('&'); 37 | fullUrl = `${fullUrl}&${dataUrl}`; 38 | } 39 | } else { 40 | if (data && typeof data !== 'object') { 41 | throw new Error('Data must be provided as an object'); 42 | } 43 | 44 | data.endpoint = endpoint; 45 | postBody = JSON.stringify(data); 46 | } 47 | 48 | return new Promise( 49 | (resolve, reject) => { 50 | fetch(fullUrl, { 51 | method, 52 | body: postBody, 53 | }).then( 54 | (response) => { 55 | if (!response.ok) { 56 | response.json().then( 57 | (jsonData) => { 58 | resolve({ 59 | success: false, 60 | error: jsonData.error, 61 | data: jsonData.data || null, 62 | }); 63 | }, 64 | () => { 65 | reject(new Error('Unable to parse JSON response')); 66 | }, 67 | ); 68 | } else { 69 | response.json().then( 70 | (jsonData) => { 71 | if (jsonData.success) { 72 | resolve({ 73 | success: true, 74 | data: jsonData.data || null, 75 | }); 76 | } else { 77 | resolve({ 78 | success: false, 79 | error: jsonData.error, 80 | data: jsonData.data || null, 81 | }); 82 | } 83 | }, 84 | () => { 85 | reject(new Error('Unable to parse JSON response')); 86 | }, 87 | ); 88 | } 89 | }, 90 | () => { 91 | reject(new Error('An unknown AJAX error occured.')); 92 | }, 93 | ); 94 | }, 95 | ); 96 | }; 97 | }, 98 | }; 99 | -------------------------------------------------------------------------------- /src/components/Sidebar.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 58 | 59 | 131 | -------------------------------------------------------------------------------- /src/assets/scss/_variables.scss: -------------------------------------------------------------------------------- 1 | // Core variables 2 | $version: "0.5.9"; 3 | 4 | // Core features 5 | $rtl: false !default; 6 | 7 | // Core colors 8 | $primary-color: #2da7c7 !default; 9 | $primary-color-dark: darken($primary-color, 3%) !default; 10 | $primary-color-light: lighten($primary-color, 3%) !default; 11 | $secondary-color: #b1dbef !default; 12 | $secondary-color-dark: darken($secondary-color, 3%) !default; 13 | $secondary-color-light: lighten($secondary-color, 3%) !default; 14 | 15 | // Gray colors 16 | $dark-color: #366277 !default; 17 | $darker-color: #184962 !default; 18 | $light-color: #fff !default; 19 | $gray-color: #bdc8ce !default; 20 | $gray-color-dark: darken($gray-color, 30%) !default; 21 | $gray-color-light: lighten($gray-color, 20%) !default; 22 | 23 | $border-color: lighten($dark-color, 65%) !default; 24 | $border-color-dark: darken($border-color, 10%) !default; 25 | $border-color-light: lighten($border-color, 8%) !default; 26 | $bg-color: lighten($dark-color, 75%) !default; 27 | $bg-color-dark: darken($bg-color, 3%) !default; 28 | $bg-color-light: $light-color !default; 29 | 30 | // Control colors 31 | $success-color: #52A838 !default; 32 | $warning-color: #ffb700 !default; 33 | $error-color: #e85600 !default; 34 | 35 | // Other colors 36 | $code-color: #d73e48 !default; 37 | $highlight-color: #ffe9b3 !default; 38 | $body-bg: $bg-color-light !default; 39 | $body-font-color: lighten($dark-color, 5%) !default; 40 | $link-color: $primary-color !default; 41 | $link-color-dark: darken($link-color, 10%) !default; 42 | $link-color-light: lighten($link-color, 10%) !default; 43 | 44 | // Fonts 45 | $body-font-family: 'Heebo', sans-serif !default; 46 | $heading-font-family: 'Assistant', sans-serif !default; 47 | $mono-font-family: "SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier, monospace !default; 48 | $heading-font-weight: 800; 49 | $subheading-font-weight: 700; 50 | $body-font-weight: 400; 51 | $body-bold-weight: 700; 52 | 53 | // Unit sizes 54 | $unit-o: .05rem !default; 55 | $unit-h: .1rem !default; 56 | $unit-1: .2rem !default; 57 | $unit-2: .4rem !default; 58 | $unit-3: .6rem !default; 59 | $unit-4: .8rem !default; 60 | $unit-5: 1rem !default; 61 | $unit-6: 1.2rem !default; 62 | $unit-7: 1.4rem !default; 63 | $unit-8: 1.6rem !default; 64 | $unit-9: 1.8rem !default; 65 | $unit-10: 2rem !default; 66 | $unit-12: 2.4rem !default; 67 | $unit-16: 3.2rem !default; 68 | 69 | // Font sizes 70 | $html-font-size: 20px !default; 71 | $html-line-height: 1.5 !default; 72 | $font-size: .8rem !default; 73 | $font-size-sm: .7rem !default; 74 | $font-size-lg: .9rem !default; 75 | $line-height: 1.2rem !default; 76 | 77 | // Sizes 78 | $layout-spacing: $unit-8 !default; 79 | $layout-spacing-sm: $unit-4 !default; 80 | $layout-spacing-lg: $unit-16 !default; 81 | $border-radius: $unit-4 !default; 82 | $border-width: $unit-o !default; 83 | $border-width-lg: $unit-h !default; 84 | $control-size: $unit-12 !default; 85 | $control-size-sm: $unit-8 !default; 86 | $control-size-lg: $unit-16 !default; 87 | $control-padding-x: $unit-4 !default; 88 | $control-padding-x-sm: $unit-4 * .75 !default; 89 | $control-padding-x-lg: $unit-4 * 1.5 !default; 90 | $control-padding-y: calc(($control-size - $line-height) / 2) - $border-width !default; 91 | $control-padding-y-sm: calc(($control-size-sm - $line-height) / 2) - $border-width !default; 92 | $control-padding-y-lg: calc(($control-size-lg - $line-height) / 2) - $border-width !default; 93 | $control-icon-size: .8rem !default; 94 | 95 | $control-width-xs: 180px !default; 96 | $control-width-sm: 320px !default; 97 | $control-width-md: 640px !default; 98 | $control-width-lg: 960px !default; 99 | $control-width-xl: 1280px !default; 100 | 101 | // Responsive breakpoints 102 | $size-xs: 480px !default; 103 | $size-sm: 600px !default; 104 | $size-md: 840px !default; 105 | $size-lg: 960px !default; 106 | $size-xl: 1280px !default; 107 | $size-2x: 1440px !default; 108 | 109 | $responsive-breakpoint: $size-xs !default; 110 | 111 | // Z-index 112 | $zindex-0: 1 !default; 113 | $zindex-1: 100 !default; 114 | $zindex-2: 200 !default; 115 | $zindex-3: 300 !default; 116 | $zindex-4: 400 !default; 117 | -------------------------------------------------------------------------------- /src/components/steps/Complete.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 60 | 61 | 172 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Winter CMS Web Installer 2 | 3 | [![Discord](https://img.shields.io/discord/816852513684193281?label=discord&style=flat-square)](https://discord.gg/D5MFSPH6Ux) 4 | 5 | ![Web Installer Preview](https://raw.githubusercontent.com/wintercms/web-installer/main/.github/web-installer.jpg) 6 | 7 | A web-based installation script that will install a fresh copy of Winter CMS. This is the recommended method of installation for **non-technical users**. It is simpler than the command-line installation or Composer methods and does not require any special skills. 8 | 9 | For developers, we recommend the following methods: 10 | 11 | - [Composer installation](https://wintercms.com/docs/v1.2/docs/architecture/using-composer#installing-winter-via-composer) 12 | - [Command-line installation](https://github.com/wintercms/cli) 13 | 14 | ## Installation 15 | 16 | > **NOTE:** This installer installs Winter CMS 1.2, which requires PHP 8.0 or above. If you are still running PHP 7.4 or below, please use [v1.0.2](https://github.com/wintercms/web-installer/releases/download/v1.0.2/install.zip) of this installer, which installs Winter 1.1. Do note that this branch of Winter is now end-of-life and will only be receiving security fixes. 17 | 18 | 1. Prepare an empty directory on the web server that will host your Winter CMS installation. It can be a main domain, sub-domain or subfolder. 19 | 2. [Download the latest "install.zip" file](https://github.com/wintercms/web-installer/releases/latest/download/install.zip) into this folder. 20 | 3. Unzip the downloaded ZIP file. 21 | 4. Grant write permissions to all files and folders that were extracted. 22 | 5. In your web browser, navigate to the URL pointing to that folder, and include `/install.html` at the end of the URL. 23 | 6. Follow the instructions given in the installer. 24 | 25 | ## Links 26 | 27 | For further information and requirements for Winter CMS, [consult the documentation](https://wintercms.com/docs). To review the files that will be installed, visit the [main Winter CMS repository](https://github.com/wintercms/winter). 28 | 29 | ## Development 30 | 31 | The Web installer is built on [VueJS](https://vuejs.org) and uses a primarily NodeJS-driven development pipeline, but does 32 | contain a PHP API element to run checks on the system and perform the installation of Winter CMS. You will need the following: 33 | 34 | - `node` v14 or above. 35 | - `npm` v6 or above. 36 | - `php` v8.0 or above 37 | 38 | To install all necessary dependencies, run the following: 39 | 40 | ``` 41 | npm install 42 | ``` 43 | 44 | You will also need to install the PHP dependencies located in the `public/install/api` directory. Run the following from the root directory to do so: 45 | 46 | ``` 47 | composer --working-dir=./public/install/api install 48 | ``` 49 | 50 | 51 | ### Development server (hot-reloading) 52 | 53 | The included dependencies include a simple web-server which performs hot-reloading of the app when changes are made. To start this server, run the following: 54 | 55 | ``` 56 | npm run dev 57 | ``` 58 | 59 | You will be given a URL in which you can review the application in your browser. 60 | 61 | When developing in this format, you will need to provide a URL in order to access the PHP API file located in `public/install/api.php`. You will need to create a file called `.env.local` inside the root folder of your development copy, and add the following line: 62 | 63 | ``` 64 | VITE_APP_INSTALL_URL="" 65 | ``` 66 | 67 | Substituting `` with the URL to the `public/install` directory as it would be located on your web server. You can run the in-built PHP server to serve this, by navigating to this folder and running the following command: 68 | 69 | ``` 70 | php -S 127.0.0.1:8081 71 | ``` 72 | 73 | In following the above, the `.env.local` file should contain the following: 74 | 75 | ``` 76 | VITE_APP_INSTALL_URL="http://127.0.0.1:8081" 77 | ``` 78 | 79 | ### Building the application 80 | 81 | Building the application is a piece of cake! Simply run the following: 82 | 83 | ``` 84 | npm run build 85 | ``` 86 | 87 | The build will then be made available in the `dist` subfolder. 88 | 89 | ### Linting 90 | 91 | This application is built on a strict code formatting and quality standard, and will be checked on commit. If you want to fix common issues and check that your code meets the standard, you can run the following: 92 | 93 | ``` 94 | npm run lint 95 | ``` 96 | -------------------------------------------------------------------------------- /src/components/Check.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 33 | 34 | 196 | -------------------------------------------------------------------------------- /src/Installer.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 137 | 138 | 179 | -------------------------------------------------------------------------------- /src/components/steps/FinalChecks.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 189 | 190 | 203 | -------------------------------------------------------------------------------- /src/components/Click.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 123 | 124 | 270 | -------------------------------------------------------------------------------- /src/components/steps/Installation.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 149 | 150 | 260 | -------------------------------------------------------------------------------- /src/components/steps/Checks.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 236 | 237 | 250 | -------------------------------------------------------------------------------- /src/assets/scss/_forms.scss: -------------------------------------------------------------------------------- 1 | // Forms 2 | .form-group { 3 | position: relative; 4 | 5 | &:not(:last-child) { 6 | margin-bottom: $layout-spacing-sm; 7 | } 8 | } 9 | 10 | fieldset { 11 | margin-bottom: $layout-spacing-lg; 12 | } 13 | 14 | legend { 15 | font-size: $font-size-lg; 16 | font-weight: 500; 17 | margin-bottom: $layout-spacing-lg; 18 | } 19 | 20 | // Form element: Label 21 | .form-label { 22 | display: block; 23 | line-height: $line-height; 24 | font-family: $heading-font-family; 25 | font-weight: $heading-font-weight; 26 | font-size: 1em; 27 | margin-bottom: $unit-1; 28 | 29 | &.label-sm { 30 | font-size: 0.9em; 31 | } 32 | 33 | &.label-lg { 34 | font-size: 1.2em; 35 | } 36 | 37 | &.label-required { 38 | &::after { 39 | content: ' •'; 40 | color: $code-color; 41 | } 42 | } 43 | } 44 | 45 | .help { 46 | display: block; 47 | margin-top: $unit-1 * -1; 48 | margin-bottom: $unit-1; 49 | } 50 | 51 | // Form element: Input 52 | .form-input { 53 | appearance: none; 54 | background: $bg-color-light; 55 | background-image: none; 56 | border: $border-width solid $border-color-dark; 57 | border-radius: $border-radius; 58 | color: $body-font-color; 59 | display: block; 60 | font-size: $font-size; 61 | height: $control-size; 62 | line-height: $line-height; 63 | max-width: 100%; 64 | outline: none; 65 | padding: $control-padding-y $control-padding-x; 66 | position: relative; 67 | transition: background .2s, border .2s, box-shadow .2s, color .2s; 68 | width: 100%; 69 | &:focus { 70 | @include control-shadow(); 71 | border-color: $primary-color; 72 | } 73 | &::placeholder { 74 | color: $gray-color-dark; 75 | } 76 | 77 | // Input sizes 78 | &.input-sm { 79 | font-size: $font-size-sm; 80 | height: $control-size-sm; 81 | padding: $control-padding-y-sm $control-padding-x; 82 | } 83 | 84 | &.input-lg { 85 | font-size: $font-size-lg; 86 | height: $control-size-lg; 87 | padding: $control-padding-y-lg $control-padding-x; 88 | } 89 | 90 | &.input-inline { 91 | display: inline-block; 92 | vertical-align: middle; 93 | width: auto; 94 | } 95 | 96 | // Input types 97 | &[type="file"] { 98 | height: auto; 99 | } 100 | } 101 | 102 | // Form element: Textarea 103 | textarea.form-input { 104 | &, 105 | &.input-lg, 106 | &.input-sm { 107 | height: auto; 108 | } 109 | } 110 | 111 | // Form element: Input hint 112 | .form-input-hint { 113 | color: $gray-color; 114 | font-size: $font-size-sm; 115 | margin-top: $unit-1; 116 | 117 | .has-success &, 118 | .is-success + & { 119 | color: $success-color; 120 | } 121 | 122 | .has-error &, 123 | .is-error + & { 124 | color: $error-color; 125 | } 126 | } 127 | 128 | // Form element: Select 129 | .form-select { 130 | appearance: none; 131 | border: $border-width solid $border-color-dark; 132 | border-radius: $border-radius; 133 | color: inherit; 134 | font-size: $font-size; 135 | height: $control-size; 136 | line-height: $line-height; 137 | outline: none; 138 | padding: $control-padding-y $control-padding-x; 139 | vertical-align: middle; 140 | width: 100%; 141 | background: $bg-color-light; 142 | &:focus { 143 | @include control-shadow(); 144 | border-color: $primary-color; 145 | } 146 | &::-ms-expand { 147 | display: none; 148 | } 149 | 150 | // Select sizes 151 | &.select-sm { 152 | font-size: $font-size-sm; 153 | height: $control-size-sm; 154 | padding: $control-padding-y-sm ($control-icon-size + $control-padding-x-sm) $control-padding-y-sm $control-padding-x-sm; 155 | } 156 | 157 | &.select-lg { 158 | font-size: $font-size-lg; 159 | height: $control-size-lg; 160 | padding: $control-padding-y-lg ($control-icon-size + $control-padding-x-lg) $control-padding-y-lg $control-padding-x-lg; 161 | } 162 | 163 | // Multiple select 164 | &[size], 165 | &[multiple] { 166 | height: auto; 167 | padding: $control-padding-y $control-padding-x; 168 | 169 | option { 170 | padding: $unit-h $unit-1; 171 | } 172 | } 173 | &:not([multiple]):not([size]) { 174 | background: $bg-color-light url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%204%205'%3E%3Cpath%20fill='%23667189'%20d='M2%200L0%202h4zm0%205L0%203h4z'/%3E%3C/svg%3E") no-repeat right .35rem center / .4rem .5rem; 175 | padding-right: $control-icon-size + $control-padding-x; 176 | } 177 | } 178 | 179 | // Form Icons 180 | .has-icon-left, 181 | .has-icon-right { 182 | position: relative; 183 | 184 | .form-icon { 185 | height: $control-icon-size; 186 | margin: 0 $control-padding-y; 187 | position: absolute; 188 | top: 50%; 189 | transform: translateY(-50%); 190 | width: $control-icon-size; 191 | z-index: $zindex-0 + 1; 192 | } 193 | } 194 | 195 | .has-icon-left { 196 | .form-icon { 197 | left: $border-width; 198 | } 199 | 200 | .form-input { 201 | padding-left: $control-icon-size + $control-padding-y * 2; 202 | } 203 | } 204 | 205 | .has-icon-right { 206 | .form-icon { 207 | right: $border-width; 208 | } 209 | 210 | .form-input { 211 | padding-right: $control-icon-size + $control-padding-y * 2; 212 | } 213 | } 214 | 215 | // Form element: Checkbox and Radio 216 | .form-checkbox, 217 | .form-radio, 218 | .form-switch { 219 | display: block; 220 | line-height: $line-height; 221 | margin: calc(($control-size - $control-size-sm) / 2) 0; 222 | min-height: $control-size-sm; 223 | padding: calc(($control-size-sm - $line-height) / 2) $control-padding-x calc(($control-size-sm - $line-height) / 2) ($control-icon-size + $control-padding-x); 224 | position: relative; 225 | 226 | input { 227 | clip: rect(0, 0, 0, 0); 228 | height: 1px; 229 | margin: -1px; 230 | overflow: hidden; 231 | position: absolute; 232 | width: 1px; 233 | &:focus + .form-icon { 234 | @include control-shadow(); 235 | border-color: $primary-color; 236 | } 237 | &:checked + .form-icon { 238 | background: $primary-color; 239 | border-color: $primary-color; 240 | } 241 | } 242 | 243 | .form-icon { 244 | border: $border-width solid $border-color-dark; 245 | cursor: pointer; 246 | display: inline-block; 247 | position: absolute; 248 | transition: background .2s, border .2s, box-shadow .2s, color .2s; 249 | } 250 | 251 | // Input checkbox, radio and switch sizes 252 | &.input-sm { 253 | font-size: $font-size-sm; 254 | margin: 0; 255 | } 256 | 257 | &.input-lg { 258 | font-size: $font-size-lg; 259 | margin: calc(($control-size-lg - $control-size-sm) / 2) 0; 260 | } 261 | } 262 | 263 | .form-checkbox, 264 | .form-radio { 265 | .form-icon { 266 | background: $bg-color-light; 267 | height: $control-icon-size; 268 | left: 0; 269 | top: calc(($control-size-sm - $control-icon-size) / 2); 270 | width: $control-icon-size; 271 | } 272 | 273 | input { 274 | &:active + .form-icon { 275 | background: $bg-color-dark; 276 | } 277 | } 278 | } 279 | .form-checkbox { 280 | .form-icon { 281 | border-radius: $border-radius; 282 | } 283 | 284 | input { 285 | &:checked + .form-icon { 286 | &::before { 287 | background-clip: padding-box; 288 | border: $border-width-lg solid $light-color; 289 | border-left-width: 0; 290 | border-top-width: 0; 291 | content: ""; 292 | height: 9px; 293 | left: 50%; 294 | margin-left: -3px; 295 | margin-top: -6px; 296 | position: absolute; 297 | top: 50%; 298 | transform: rotate(45deg); 299 | width: 6px; 300 | } 301 | } 302 | &:indeterminate + .form-icon { 303 | background: $primary-color; 304 | border-color: $primary-color; 305 | &::before { 306 | background: $bg-color-light; 307 | content: ""; 308 | height: 2px; 309 | left: 50%; 310 | margin-left: -5px; 311 | margin-top: -1px; 312 | position: absolute; 313 | top: 50%; 314 | width: 10px; 315 | } 316 | } 317 | } 318 | } 319 | .form-radio { 320 | .form-icon { 321 | border-radius: 50%; 322 | } 323 | 324 | input { 325 | &:checked + .form-icon { 326 | &::before { 327 | background: $bg-color-light; 328 | border-radius: 50%; 329 | content: ""; 330 | height: 6px; 331 | left: 50%; 332 | position: absolute; 333 | top: 50%; 334 | transform: translate(-50%, -50%); 335 | width: 6px; 336 | } 337 | } 338 | } 339 | } 340 | 341 | // Form element: Switch 342 | .form-switch { 343 | padding-left: ($unit-8 + $control-padding-x); 344 | 345 | .form-icon { 346 | background: $gray-color; 347 | background-clip: padding-box; 348 | border-radius: $unit-2 + $border-width; 349 | height: $unit-4 + $border-width * 2; 350 | left: 0; 351 | top: calc(($control-size-sm - $unit-4) / 2) - $border-width; 352 | width: $unit-8; 353 | &::before { 354 | background: $bg-color-light; 355 | border-radius: 50%; 356 | content: ""; 357 | display: block; 358 | height: $unit-4; 359 | left: 0; 360 | position: absolute; 361 | top: 0; 362 | transition: background .2s, border .2s, box-shadow .2s, color .2s, left .2s; 363 | width: $unit-4; 364 | } 365 | } 366 | 367 | input { 368 | &:checked + .form-icon { 369 | &::before { 370 | left: 14px; 371 | } 372 | } 373 | &:active + .form-icon { 374 | &::before { 375 | background: $bg-color; 376 | } 377 | } 378 | } 379 | } 380 | 381 | // Form element: Input groups 382 | .input-group { 383 | display: flex; 384 | 385 | .input-group-addon { 386 | background: $bg-color; 387 | border: $border-width solid $border-color-dark; 388 | border-radius: $border-radius; 389 | line-height: $line-height; 390 | padding: $control-padding-y $control-padding-x; 391 | white-space: nowrap; 392 | 393 | &.addon-sm { 394 | font-size: $font-size-sm; 395 | padding: $control-padding-y-sm $control-padding-x-sm; 396 | } 397 | 398 | &.addon-lg { 399 | font-size: $font-size-lg; 400 | padding: $control-padding-y-lg $control-padding-x-lg; 401 | } 402 | } 403 | 404 | .form-input, 405 | .form-select { 406 | flex: 1 1 auto; 407 | width: 1%; 408 | } 409 | 410 | .input-group-btn { 411 | z-index: $zindex-0; 412 | } 413 | 414 | .form-input, 415 | .form-select, 416 | .input-group-addon, 417 | .input-group-btn { 418 | &:first-child:not(:last-child) { 419 | border-bottom-right-radius: 0; 420 | border-top-right-radius: 0; 421 | } 422 | &:not(:first-child):not(:last-child) { 423 | border-radius: 0; 424 | margin-left: -$border-width; 425 | } 426 | &:last-child:not(:first-child) { 427 | border-bottom-left-radius: 0; 428 | border-top-left-radius: 0; 429 | margin-left: -$border-width; 430 | } 431 | &:focus { 432 | z-index: $zindex-0 + 1; 433 | } 434 | } 435 | 436 | .form-select { 437 | width: auto; 438 | } 439 | 440 | &.input-inline { 441 | display: inline-flex; 442 | } 443 | } 444 | 445 | // Form validation states 446 | .form-input, 447 | .form-select { 448 | .has-success &, 449 | &.is-success { 450 | background: lighten($success-color, 53%); 451 | border-color: $success-color; 452 | &:focus { 453 | @include control-shadow($success-color); 454 | } 455 | } 456 | 457 | .has-error &, 458 | &.is-error { 459 | background: lighten($error-color, 53%); 460 | border-color: $error-color; 461 | &:focus { 462 | @include control-shadow($error-color); 463 | } 464 | } 465 | } 466 | 467 | .form-checkbox, 468 | .form-radio, 469 | .form-switch { 470 | .has-error &, 471 | &.is-error { 472 | .form-icon { 473 | border-color: $error-color; 474 | } 475 | 476 | input { 477 | &:checked + .form-icon { 478 | background: $error-color; 479 | border-color: $error-color; 480 | } 481 | 482 | &:focus + .form-icon { 483 | @include control-shadow($error-color); 484 | border-color: $error-color; 485 | } 486 | } 487 | } 488 | } 489 | 490 | .form-checkbox { 491 | .has-error &, 492 | &.is-error { 493 | input { 494 | &:indeterminate + .form-icon { 495 | background: $error-color; 496 | border-color: $error-color; 497 | } 498 | } 499 | } 500 | } 501 | 502 | // validation based on :placeholder-shown (Edge doesn't support it yet) 503 | .form-input { 504 | &:not(:placeholder-shown) { 505 | &:invalid { 506 | border-color: $error-color; 507 | &:focus { 508 | @include control-shadow($error-color); 509 | background: lighten($error-color, 53%); 510 | } 511 | 512 | & + .form-input-hint { 513 | color: $error-color; 514 | } 515 | } 516 | } 517 | } 518 | 519 | // Form disabled and readonly 520 | .form-input, 521 | .form-select { 522 | &:disabled, 523 | &.disabled { 524 | background-color: $bg-color-dark; 525 | cursor: not-allowed; 526 | opacity: .5; 527 | } 528 | } 529 | 530 | .form-input { 531 | &[readonly] { 532 | background-color: $bg-color; 533 | } 534 | } 535 | 536 | input { 537 | &:disabled, 538 | &.disabled { 539 | & + .form-icon { 540 | background: $bg-color-dark; 541 | cursor: not-allowed; 542 | opacity: .5; 543 | } 544 | } 545 | } 546 | 547 | .form-switch { 548 | input { 549 | &:disabled, 550 | &.disabled { 551 | & + .form-icon::before { 552 | background: $bg-color-light; 553 | } 554 | } 555 | } 556 | } 557 | 558 | // Form horizontal 559 | .form-horizontal { 560 | padding: $layout-spacing 0; 561 | 562 | .form-group { 563 | display: flex; 564 | flex-wrap: wrap; 565 | } 566 | } 567 | 568 | // Form inline 569 | .form-inline { 570 | display: inline-block; 571 | } 572 | 573 | // Form error 574 | .form-error { 575 | display: block; 576 | position: absolute; 577 | bottom: 25%; 578 | left: -25px; 579 | height: 36px; 580 | line-height: 36px; 581 | padding: 0 0 0 $unit-2; 582 | transform: translateX(-100%) translateY(50%); 583 | box-shadow: 2px 4px 0px 0px rgba(0, 0, 0, 0.08); 584 | 585 | background: $error-color; 586 | color: #fff; 587 | font-size: 0.65rem; 588 | font-weight: bold; 589 | border-top-left-radius: $border-radius; 590 | border-bottom-left-radius: $border-radius; 591 | 592 | &::before { 593 | content: ''; 594 | position: absolute; 595 | top: 4px; 596 | right: -2px; 597 | margin-right: -36px; 598 | border: 18px solid transparent; 599 | border-left-color: rgba(0, 0, 0, 0.08); 600 | } 601 | 602 | &::after { 603 | content: ''; 604 | position: absolute; 605 | top: 0; 606 | right: 0; 607 | margin-right: -36px; 608 | border: 18px solid transparent; 609 | border-left-color: $error-color; 610 | } 611 | } 612 | 613 | 614 | -------------------------------------------------------------------------------- /src/components/steps/Configuration.vue: -------------------------------------------------------------------------------- 1 | 415 | 416 | 538 | 539 | 544 | -------------------------------------------------------------------------------- /public/install/api/src/Api.php: -------------------------------------------------------------------------------- 1 | ` in camel-case to process the 24 | * endpoint (eg. for a POST call to the `createDatabase` endpoint, the API will run the `postCreateDatabase` method). 25 | * 26 | * Any data that is sent in the query strings for GET, and in the post data for POST, will be available within this 27 | * method inside the `$this->data` variable. 28 | * 29 | * @author Ben Thomson 30 | * @author Winter CMS 31 | * @since 1.0.0 32 | */ 33 | class Api 34 | { 35 | // Minimum PHP version required for Winter CMS 36 | const MIN_PHP_VERSION = '8.0.2'; 37 | 38 | // Minimum PHP version that is unsupported for Winter CMS (upper limit) 39 | const MAX_PHP_VERSION = '8.999.999'; 40 | 41 | // Winter CMS API URL 42 | const API_URL = 'https://api.wintercms.com/marketplace'; 43 | 44 | // Winter CMS codebase archive 45 | const WINTER_ARCHIVE = 'https://github.com/wintercms/winter/archive/refs/heads/1.2.zip'; 46 | 47 | // Archive subfolder 48 | const ARCHIVE_SUBFOLDER = 'winter-1.2/'; 49 | 50 | /** @var Logger */ 51 | protected $log; 52 | 53 | /** @var string Requested endpoint */ 54 | protected $endpoint; 55 | 56 | /** @var string Request method of last API call */ 57 | protected $method; 58 | 59 | /** @var array Request data of last API call */ 60 | protected $data = []; 61 | 62 | /** @var int Response code */ 63 | protected $responseCode = 200; 64 | 65 | /** 66 | * Main endpoint, processes an incoming request and generates a response. 67 | * 68 | * @return void 69 | */ 70 | public function request() 71 | { 72 | // Disable display errors to prevent corruption of JSON responses 73 | ini_set('display_errors', 'Off'); 74 | 75 | $this->initialiseLogging(); 76 | 77 | $this->setExceptionHandler(); 78 | 79 | $this->parseRequest(); 80 | 81 | $this->log->info('Installer API request received', [ 82 | 'method' => $this->method, 83 | 'endpoint' => $this->endpoint, 84 | ]); 85 | 86 | $method = $this->getRequestedMethod(); 87 | if (is_null($method)) { 88 | $this->error('Invalid Installer API endpoint requested', 404); 89 | } 90 | 91 | $this->{$method}(); 92 | 93 | $this->response(true); 94 | } 95 | 96 | /** 97 | * GET /api.php?endpoint=checkApi 98 | * 99 | * Checks that the Winter CMS Marketplace API is available. 100 | * 101 | * @return void 102 | */ 103 | public function getCheckApi() 104 | { 105 | $this->log->notice('Trying Winter CMS API'); 106 | $response = $this->apiRequest('GET', 'ping'); 107 | $this->log->notice('Response received from Winter CMS API', ['response' => $response]); 108 | 109 | if ($response !== 'pong') { 110 | $this->error('Winter CMS API is unavailable', 500); 111 | } 112 | 113 | $this->log->notice('Winter CMS API connection successful.'); 114 | } 115 | 116 | /** 117 | * POST /api.php[endpoint=ignoreCerts] 118 | * 119 | * Ignores SSL certificate validation issues for installer. 120 | * 121 | * @return void 122 | */ 123 | public function postIgnoreCerts() 124 | { 125 | $this->log->notice('Ignoring SSL certificate validation issues'); 126 | touch($this->rootDir('.ignore-ssl')); 127 | } 128 | 129 | /** 130 | * GET /api.php?endpoint=checkPhpVersion 131 | * 132 | * Checks that the currently-running version of PHP matches the minimum required for Winter CMS (1.2 branch) 133 | * 134 | * @return void 135 | */ 136 | public function getCheckPhpVersion() 137 | { 138 | $hasVersion = ( 139 | version_compare(trim(strtolower(PHP_VERSION)), self::MIN_PHP_VERSION, '>=') 140 | && version_compare(trim(strtolower(PHP_VERSION)), self::MAX_PHP_VERSION, '<') 141 | ); 142 | 143 | $this->data = [ 144 | 'detected' => PHP_VERSION, 145 | 'needed' => self::MIN_PHP_VERSION, 146 | 'installPath' => $this->rootDir(), 147 | ]; 148 | 149 | $this->log->notice('Compared PHP version', [ 150 | 'installed' => PHP_VERSION, 151 | 'needed' => self::MIN_PHP_VERSION 152 | ]); 153 | 154 | if (!$hasVersion) { 155 | $this->error('PHP version requirement not met.'); 156 | } 157 | 158 | $this->log->notice('PHP version requirement met.'); 159 | } 160 | 161 | /** 162 | * GET /api.php?endpoint=checkPhpExtensions 163 | * 164 | * Checks that necessary extensions required for running Winter CMS are installed and enabled. 165 | * 166 | * @return void 167 | */ 168 | public function getCheckPhpExtensions() 169 | { 170 | $this->log->notice('Checking PHP "curl" extension'); 171 | if (!function_exists('curl_init') || !defined('CURLOPT_FOLLOWLOCATION')) { 172 | $this->data['extension'] = 'curl'; 173 | $this->error('Missing extension'); 174 | } 175 | 176 | $this->log->notice('Checking PHP "json" extension'); 177 | if (!function_exists('json_decode')) { 178 | $this->data['extension'] = 'json'; 179 | $this->error('Missing extension'); 180 | } 181 | 182 | $this->log->notice('Checking PHP "pdo" extension'); 183 | if (!defined('PDO::ATTR_DRIVER_NAME')) { 184 | $this->data['extension'] = 'pdo'; 185 | $this->error('Missing extension'); 186 | } 187 | 188 | $this->log->notice('Checking PHP "zip" extension'); 189 | if (!class_exists('ZipArchive')) { 190 | $this->data['extension'] = 'zip'; 191 | $this->error('Missing extension'); 192 | } 193 | 194 | $extensions = ['mbstring', 'fileinfo', 'openssl', 'gd', 'filter', 'hash', 'dom']; 195 | foreach ($extensions as $ext) { 196 | $this->log->notice('Checking PHP "' . $ext . '" extension'); 197 | 198 | if (!extension_loaded($ext)) { 199 | $this->data['extension'] = $ext; 200 | $this->error('Missing extension'); 201 | } 202 | } 203 | 204 | $this->log->notice('Required PHP extensions are installed.'); 205 | } 206 | 207 | /** 208 | * POST /api.php[endpoint=checkDatabase] 209 | * 210 | * Checks that the given database credentials can be used to connect to a valid, empty database. 211 | * 212 | * @return void 213 | */ 214 | public function postCheckDatabase() 215 | { 216 | set_time_limit(60); 217 | 218 | $dbConfig = $this->data['site']['database']; 219 | 220 | // Create a temporary SQLite database if necessary 221 | if ($dbConfig['type'] === 'sqlite') { 222 | $this->log->notice('Creating temporary SQLite DB', ['path' => $this->rootDir('.temp.sqlite')]); 223 | 224 | try { 225 | if (!is_file($this->rootDir('.temp.sqlite'))) { 226 | touch($this->rootDir('.temp.sqlite')); 227 | } 228 | } catch (\Throwable $e) { 229 | $this->data['exception'] = $e->getMessage(); 230 | $this->error('Unable to create a temporary SQLite database.'); 231 | } 232 | } 233 | 234 | try { 235 | $this->log->notice('Check database connection'); 236 | $capsule = $this->createCapsule($dbConfig); 237 | $connection = $capsule->getConnection(); 238 | 239 | $tables = $connection->getDoctrineSchemaManager()->listTableNames(); 240 | $this->log->notice('Found ' . count($tables) . ' table(s)', ['tables' => implode(', ', $tables)]); 241 | } catch (\Throwable $e) { 242 | $this->data['exception'] = $e->getMessage(); 243 | $this->error('Database could not be connected to.'); 244 | } 245 | 246 | if (count($tables)) { 247 | $this->data['dbNotEmpty'] = true; 248 | $this->error('Database is not empty.'); 249 | } 250 | 251 | $this->log->notice('Database connection established and verified empty'); 252 | } 253 | 254 | /** 255 | * GET /api.php?endpoint=checkWriteAccess 256 | * 257 | * Checks that the current work directory is writable. 258 | * 259 | * @return void 260 | */ 261 | public function getCheckWriteAccess() 262 | { 263 | if (!is_writable($this->rootDir())) { 264 | $this->data['writable'] = false; 265 | $this->error('Current working directory is not writable.'); 266 | } 267 | 268 | $this->data['writable'] = true; 269 | $this->log->notice('Current working directory is writable.'); 270 | } 271 | 272 | /** 273 | * POST /api.php[endpoint=downloadWinter] 274 | * 275 | * Downloads the Winter CMS codebase from the 1.2 branch. 276 | * 277 | * @return void 278 | */ 279 | public function postDownloadWinter() 280 | { 281 | set_time_limit(360); 282 | 283 | if (is_dir($this->workDir())) { 284 | $this->rimraf($this->workDir()); 285 | } 286 | if (!@mkdir($this->workDir(), 0755, true)) { 287 | $this->error('Unable to create a work directory for installation'); 288 | } 289 | 290 | $winterZip = $this->workDir('winter.zip'); 291 | 292 | if (!file_exists($winterZip)) { 293 | $this->log->notice('Try downloading Winter CMS archive'); 294 | 295 | try { 296 | $fp = fopen($winterZip, 'w'); 297 | if (!$fp) { 298 | $this->log->error('Winter ZIP file unwritable', ['path' => $winterZip]); 299 | $this->error('Unable to write the Winter installation file'); 300 | } 301 | $curl = curl_init(); 302 | 303 | // Set default params 304 | $params['client'] = 'winter-installer'; 305 | 306 | curl_setopt_array($curl, [ 307 | CURLOPT_URL => self::WINTER_ARCHIVE, 308 | CURLOPT_RETURNTRANSFER => true, 309 | CURLOPT_TIMEOUT => 300, 310 | CURLOPT_FOLLOWLOCATION => true, 311 | CURLOPT_MAXREDIRS => 5, 312 | CURLOPT_FILE => $fp 313 | ]); 314 | 315 | if (file_exists($this->rootDir('.ignore-ssl'))) { 316 | curl_setopt_array($curl, [ 317 | CURLOPT_SSL_VERIFYPEER => false, 318 | CURLOPT_SSL_VERIFYHOST => 0, 319 | ]); 320 | } else { 321 | curl_setopt_array($curl, [ 322 | CURLOPT_SSL_VERIFYPEER => true, 323 | CURLOPT_SSL_VERIFYHOST => 2, 324 | ]); 325 | } 326 | 327 | $this->log->notice('Downloading Winter ZIP via cURL', ['url' => self::WINTER_ARCHIVE]); 328 | curl_exec($curl); 329 | $responseCode = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); 330 | 331 | if ($responseCode === 0) { 332 | $this->log->error('Unable to verify SSL certificate or connection to GitHub', []); 333 | 334 | curl_close($curl); 335 | 336 | throw new SSLValidationException('Unable to verify SSL certificate or connection'); 337 | } elseif ($responseCode < 200 || $responseCode > 299) { 338 | throw new \Exception('Invalid HTTP code received - got ' . $responseCode); 339 | } 340 | 341 | curl_close($curl); 342 | } catch (\Throwable $e) { 343 | if (isset($fp)) { 344 | fclose($fp); 345 | } 346 | if (isset($curl) && is_resource($curl)) { 347 | curl_close($curl); 348 | } 349 | $this->error('Unable to download Winter CMS. ' . $e->getMessage()); 350 | } 351 | 352 | $this->log->notice('Winter CMS ZIP file downloaded', ['path' => $winterZip]); 353 | } else { 354 | $this->log->notice('Winter CMS ZIP file already downloaded', ['path' => $winterZip]); 355 | } 356 | } 357 | 358 | /** 359 | * POST /api.php[endpoint=extractWinter] 360 | * 361 | * Extracts the downloaded ZIP file. 362 | * 363 | * @return void 364 | */ 365 | public function postExtractWinter() 366 | { 367 | set_time_limit(120); 368 | 369 | $winterZip = $this->workDir('winter.zip'); 370 | 371 | if (!file_exists($winterZip)) { 372 | $this->error('Winter CMS Zip file not found.'); 373 | return; 374 | } 375 | 376 | try { 377 | $this->log->notice('Begin extracting Winter CMS archive'); 378 | $zip = new ZipArchive(); 379 | $zip->open($winterZip); 380 | } catch (\Throwable $e) { 381 | $this->error('Unable to extract Winter CMS. ' . $e->getMessage()); 382 | } 383 | 384 | $zip->extractTo($this->workDir()); 385 | 386 | if (!empty(self::ARCHIVE_SUBFOLDER)) { 387 | $this->log->notice('Move subfoldered files into position', ['subfolder' => self::ARCHIVE_SUBFOLDER]); 388 | 389 | // Move files from subdirectory into install folder 390 | $dir = new DirectoryIterator($this->workDir(self::ARCHIVE_SUBFOLDER)); 391 | 392 | foreach ($dir as $item) { 393 | if ($item->isDot()) { 394 | continue; 395 | } 396 | 397 | $relativePath = str_replace($this->workDir(self::ARCHIVE_SUBFOLDER), '', $item->getPathname()); 398 | 399 | rename($item->getPathname(), $this->workDir($relativePath)); 400 | } 401 | } 402 | 403 | // Clean up 404 | $zip->close(); 405 | if (!empty(self::ARCHIVE_SUBFOLDER)) { 406 | $this->log->notice('Remove ZIP subfolder', ['subfolder' => self::ARCHIVE_SUBFOLDER]); 407 | rmdir($this->workDir(self::ARCHIVE_SUBFOLDER)); 408 | } 409 | 410 | // Make artisan command-line tool executable 411 | $this->log->notice('Make artisan command-line tool executable', ['path' => $this->workDir('artisan')]); 412 | chmod($this->workDir('artisan'), 0755); 413 | 414 | // If using SQLite, move temp SQLite DB into position 415 | if ($this->data['site']['database']['type'] === 'sqlite' && is_file($this->rootDir('.temp.sqlite'))) { 416 | $this->log->notice('Move temp SQLite DB into position', [ 417 | 'from' => $this->rootDir('.temp.sqlite'), 418 | 'to' => $this->workDir('storage/database.sqlite') 419 | ]); 420 | rename($this->rootDir('.temp.sqlite'), $this->workDir('storage/database.sqlite')); 421 | } 422 | 423 | // Rewrite composer.json to exclude the Composer Merge plugin, it seems to force the use of Symfony/Process and 424 | // the "proc_open" method which is commonly disabled on shared hosts. 425 | $this->log->notice('Remove Composer Merge plugin from composer.json if found', []); 426 | $composerJson = json_decode(file_get_contents($this->workDir('composer.json')), true); 427 | 428 | if (isset($composerJson['require']['wikimedia/composer-merge-plugin'])) { 429 | $this->log->notice('Found merge plugin in required packages - removing', []); 430 | unset($composerJson['require']['wikimedia/composer-merge-plugin']); 431 | } 432 | if (isset($composerJson['extra']['merge-plugin'])) { 433 | $this->log->notice('Found merge plugin config in "extra" config definition - removing', []); 434 | unset($composerJson['extra']['merge-plugin']); 435 | } 436 | 437 | if (!file_put_contents($this->workDir('composer.json'), json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES))) { 438 | $this->log->error('Unable to write new Composer config', ['path' => $this->workDir('composer.json')]); 439 | } 440 | } 441 | 442 | /** 443 | * POST /api.php[endpoint=lockDependencies] 444 | * 445 | * Locks the Composer dependencies for Winter CMS in composer.lock 446 | * 447 | * @return void 448 | */ 449 | public function postLockDependencies() 450 | { 451 | set_time_limit(360); 452 | 453 | try { 454 | $this->log->notice('Create Composer instance'); 455 | $composer = new Composer(); 456 | $this->log->notice('Set memory limit to 1.5GB'); 457 | $composer->setMemoryLimit(1536); 458 | $this->log->notice('Set work directory for Composer', ['path' => $this->workDir()]); 459 | $composer->setWorkDir($this->workDir()); 460 | 461 | $tmpHomeDir = $this->workDir('.composer'); 462 | 463 | if (!is_dir($tmpHomeDir)) { 464 | $this->log->notice('Create home/cache directory for Composer', ['path' => $tmpHomeDir]); 465 | mkdir($tmpHomeDir, 0755); 466 | } 467 | $this->log->notice('Set home/cache directory for Composer', ['path' => $tmpHomeDir]); 468 | $composer->setHomeDir($tmpHomeDir); 469 | 470 | $this->log->notice('Run Composer "update" command - generate only a lockfile'); 471 | $update = $composer->update(true, true, false, 'dist', true); 472 | } catch (\Throwable $e) { 473 | if (!empty($e->getPrevious())) { 474 | $this->log->error('Composer exception', ['exception' => $e->getPrevious()]); 475 | } 476 | $this->error('Unable to determine dependencies for Winter CMS. ' . $e->getMessage()); 477 | } 478 | 479 | $this->log->notice('Locked Composer packages', [ 480 | 'numPackages' => $update->getLockInstalledCount(), 481 | 'lockFile' => $this->workDir('composer.lock'), 482 | ]); 483 | $this->data['packagesInstalled'] = $update->getLockInstalledCount(); 484 | } 485 | 486 | /** 487 | * POST /api.php[endpoint=installDependencies] 488 | * 489 | * Installs the locked depencies from the `lockDependencies` call. 490 | * 491 | * @return void 492 | */ 493 | public function postInstallDependencies() 494 | { 495 | set_time_limit(180); 496 | 497 | try { 498 | $this->log->notice('Create Composer instance'); 499 | $composer = new Composer(); 500 | $this->log->notice('Set memory limit to 1.5GB'); 501 | $composer->setMemoryLimit(1536); 502 | $this->log->notice('Set work directory for Composer', ['path' => $this->workDir()]); 503 | $composer->setWorkDir($this->workDir()); 504 | 505 | $tmpHomeDir = $this->workDir('.composer'); 506 | 507 | if (!is_dir($tmpHomeDir)) { 508 | $this->log->notice('Create home/cache directory for Composer', ['path' => $tmpHomeDir]); 509 | mkdir($tmpHomeDir, 0755); 510 | } 511 | $this->log->notice('Set home/cache directory for Composer', ['path' => $tmpHomeDir]); 512 | $composer->setHomeDir($tmpHomeDir); 513 | 514 | $this->log->notice('Run Composer "install" command - install from lockfile'); 515 | $install = $composer->install(true, false, false, 'dist', true); 516 | } catch (\Throwable $e) { 517 | $this->error('Unable to determine dependencies for Winter CMS. ' . $e->getMessage()); 518 | } 519 | 520 | $this->log->notice('Installed Composer packages', [ 521 | 'numPackages' => $install->getInstalledCount(), 522 | ]); 523 | $this->data['packagesInstalled'] = $install->getInstalledCount(); 524 | } 525 | 526 | /** 527 | * POST /api.php[endpoint=setupConfig] 528 | * 529 | * Rewrites the default configuration files with the values provided in the installer. 530 | * 531 | * @return void 532 | */ 533 | public function postSetupConfig() 534 | { 535 | $this->bootFramework(); 536 | 537 | try { 538 | $this->log->notice('Rewriting config', ['path' => $this->workDir('config/app.php')]); 539 | $config = ArrayFile::open($this->workDir('config/app.php'), true); 540 | 541 | $config 542 | ->set([ 543 | 'name' => $this->data['site']['name'], 544 | 'url' => $this->data['site']['url'], 545 | 'key' => $this->generateKey(), 546 | ]) 547 | ->write(); 548 | 549 | $this->log->notice('Rewriting config', ['path' => $this->workDir('config/cms.php')]); 550 | $config = ArrayFile::open($this->workDir('config/cms.php'), true); 551 | 552 | $config 553 | ->set([ 554 | 'backendUri' => '/' . $this->data['site']['backendUrl'], 555 | ]) 556 | ->write(); 557 | 558 | // config/database.php 559 | $dbConfig = $this->data['site']['database']; 560 | 561 | $this->log->notice('Rewriting config', ['path' => $this->workDir('config/database.php')]); 562 | $config = ArrayFile::open($this->workDir('config/database.php'), true); 563 | 564 | if ($dbConfig['type'] === 'sqlite') { 565 | $config->set([ 566 | 'default' => 'sqlite', 567 | ]); 568 | } else { 569 | $config->set([ 570 | 'default' => $dbConfig['type'], 571 | 'connections.' . $dbConfig['type'] . '.host' => $dbConfig['host'] ?? null, 572 | 'connections.' . $dbConfig['type'] . '.port' => $dbConfig['port'] ?? $this->getDefaultDbPort($dbConfig['type']), 573 | 'connections.' . $dbConfig['type'] . '.database' => $dbConfig['name'], 574 | 'connections.' . $dbConfig['type'] . '.username' => $dbConfig['user'] ?? '', 575 | 'connections.' . $dbConfig['type'] . '.password' => $dbConfig['pass'] ?? '' 576 | ]); 577 | } 578 | $config->write(); 579 | } catch (\Throwable $e) { 580 | $this->error('Unable to write config. ' . $e->getMessage()); 581 | } 582 | 583 | // Force cache flush 584 | $opcacheEnabled = ini_get('opcache.enable'); 585 | $opcachePath = trim(ini_get('opcache.restrict_api')); 586 | 587 | if (!empty($opcachePath) && !str_starts_with(__FILE__, $opcachePath)) { 588 | $opcacheEnabled = false; 589 | } 590 | 591 | if (function_exists('opcache_reset') && $opcacheEnabled) { 592 | $this->log->notice('Flushing OPCache'); 593 | opcache_reset(); 594 | } 595 | if (function_exists('apc_clear_cache')) { 596 | $this->log->notice('Flushing APC Cache'); 597 | apc_clear_cache(); 598 | } 599 | } 600 | 601 | /** 602 | * POST /api.php[endpoint=runMigrations] 603 | * 604 | * Runs the migrations. 605 | * 606 | * @return void 607 | */ 608 | public function postRunMigrations() 609 | { 610 | set_time_limit(120); 611 | 612 | try { 613 | $this->bootFramework(); 614 | 615 | $this->log->notice('Running artisan "config:clear" command'); 616 | $output = new BufferedOutput(); 617 | \Illuminate\Support\Facades\Artisan::call('config:clear', [], $output); 618 | $this->log->notice('Command finished.', ['output' => $output->fetch()]); 619 | 620 | $this->log->notice('Running database migrations'); 621 | $output = new BufferedOutput(); 622 | \Illuminate\Support\Facades\Artisan::call('winter:up', [], $output); 623 | $this->log->notice('Command finished.', ['output' => $output->fetch()]); 624 | } catch (\Throwable $e) { 625 | $this->error('Unable to run migrations. ' . $e->getMessage()); 626 | } 627 | } 628 | 629 | /** 630 | * POST /api.php[endpoint=createAdmin] 631 | * 632 | * Creates (or updates) the administrator account. 633 | * 634 | * @return void 635 | */ 636 | public function postCreateAdmin() 637 | { 638 | try { 639 | $this->bootFramework(); 640 | 641 | $this->log->notice('Finding initial admin account'); 642 | $admin = \Backend\Models\User::find(1); 643 | } catch (\Throwable $e) { 644 | $this->error('Unable to find administrator account. ' . $e->getMessage()); 645 | } 646 | 647 | $admin->email = $this->data['site']['admin']['email']; 648 | $admin->login = $this->data['site']['admin']['username']; 649 | $admin->password = $this->data['site']['admin']['password']; 650 | $admin->password_confirmation = $this->data['site']['admin']['password']; 651 | $admin->first_name = $this->data['site']['admin']['firstName']; 652 | $admin->last_name = $this->data['site']['admin']['lastName']; 653 | 654 | try { 655 | $this->log->notice('Changing admin account to details provided in installation'); 656 | $admin->save(); 657 | } catch (\Throwable $e) { 658 | $this->error('Unable to save administrator account. ' . $e->getMessage()); 659 | } 660 | } 661 | 662 | /** 663 | * POST /api.php[endpoint=cleanUp] 664 | * 665 | * Cleans up and removes the installer and Composer cache, removes core development files, and then moves 666 | * Winter CMS files into position. 667 | * 668 | * @return void 669 | */ 670 | public function postCleanUp() 671 | { 672 | set_time_limit(120); 673 | 674 | // Remove install files 675 | $this->log->notice('Removing installation files'); 676 | $this->rimraf($this->workDir('winter.zip')); 677 | $this->rimraf($this->rootDir('install.html')); 678 | $this->rimraf($this->rootDir('install.zip')); 679 | $this->rimraf($this->rootDir('.ignore-ssl')); 680 | 681 | // Remove install folders 682 | $this->log->notice('Removing temporary installation folders'); 683 | $this->rimraf($this->rootDir('install')); 684 | $this->rimraf($this->workDir('.composer')); 685 | 686 | // Remove core development files 687 | $this->log->notice('Removing core development files'); 688 | $this->rimraf($this->workDir('.devcontainer')); 689 | $this->rimraf($this->workDir('.github')); 690 | $this->rimraf($this->workDir('.gitpod')); 691 | $this->rimraf($this->workDir('.gitattributes')); 692 | $this->rimraf($this->workDir('.gitpod.yml')); 693 | $this->rimraf($this->workDir('.jshintrc')); 694 | $this->rimraf($this->workDir('.babelrc')); 695 | $this->rimraf($this->workDir('CHANGELOG.md')); 696 | $this->rimraf($this->workDir('phpunit.xml')); 697 | $this->rimraf($this->workDir('phpcs.xml')); 698 | 699 | // Move files from subdirectory into install folder 700 | $this->log->notice('Moving files from temporary work directory to final installation path', [ 701 | 'workDir' => $this->workDir(), 702 | 'installDir' => $this->rootDir(), 703 | ]); 704 | $dir = new DirectoryIterator($this->workDir()); 705 | 706 | foreach ($dir as $item) { 707 | if ($item->isDot()) { 708 | continue; 709 | } 710 | 711 | $relativePath = str_replace($this->workDir(), '', $item->getPathname()); 712 | 713 | rename($item->getPathname(), $this->rootDir($relativePath)); 714 | } 715 | 716 | // Remove work directory 717 | $this->log->notice('Removing work directory'); 718 | rmdir($this->workDir()); 719 | 720 | $this->log->notice('Installation complete!'); 721 | } 722 | 723 | /** 724 | * Initialise the logging for the API / install. 725 | * 726 | * @return void 727 | */ 728 | protected function initialiseLogging() 729 | { 730 | // Set format 731 | $dateFormat = 'Y-m-d H:i:sP'; 732 | $logFormat = "[%datetime%] %level_name%: %message% %context% %extra%\n"; 733 | $formatter = new LineFormatter($logFormat, $dateFormat, false, true); 734 | 735 | $this->log = new Logger('install'); 736 | 737 | $stream = new StreamHandler($this->rootDir('install.log')); 738 | $stream->setFormatter($formatter); 739 | 740 | $this->log->pushHandler($stream, Logger::INFO); 741 | } 742 | 743 | /** 744 | * Parses an incoming request for use in this API class. 745 | * 746 | * The method will be available in `$this->method`. Any request data will be available in `$this->data`. 747 | * 748 | * @return void 749 | */ 750 | protected function parseRequest() 751 | { 752 | $this->method = $_SERVER['REQUEST_METHOD']; 753 | 754 | if (!in_array($this->method, ['GET', 'POST'])) { 755 | $this->error('Invalid request method. Must be one of: GET, POST', 405); 756 | return; 757 | } 758 | 759 | if ($this->method === 'GET') { 760 | $this->data = $_GET; 761 | } else { 762 | $json = file_get_contents('php://input'); 763 | 764 | if (empty($json)) { 765 | $this->error('No JSON input detected', 400); 766 | return; 767 | } 768 | 769 | $data = json_decode($json, true); 770 | 771 | if (json_last_error() !== JSON_ERROR_NONE) { 772 | $this->error('Malformed JSON request: ' . json_last_error_msg()); 773 | return; 774 | } 775 | 776 | $this->data = $data; 777 | } 778 | 779 | $this->endpoint = $this->data['endpoint'] ?? null; 780 | unset($this->data['endpoint']); 781 | 782 | if (is_null($this->endpoint)) { 783 | $this->error('Missing requested endpoint', 400); 784 | } 785 | } 786 | 787 | /** 788 | * Determines if the correct API handler method is available. 789 | * 790 | * @return void 791 | */ 792 | protected function getRequestedMethod() 793 | { 794 | $method = strtolower($this->method) . ucfirst($this->endpoint); 795 | 796 | if (!method_exists($this, $method)) { 797 | return null; 798 | } 799 | 800 | $reflection = new ReflectionMethod($this, $method); 801 | if (!$reflection->isPublic()) { 802 | return null; 803 | } 804 | 805 | return $method; 806 | } 807 | 808 | /** 809 | * Sets the HTTP response code. 810 | * 811 | * @param integer $code 812 | * @return void 813 | */ 814 | protected function setResponseCode(int $code) 815 | { 816 | $this->responseCode = $code; 817 | } 818 | 819 | /** 820 | * Generates and echoes a JSON response to the browser. 821 | * 822 | * @param boolean $success Is this is a successful response? 823 | * @return void 824 | */ 825 | protected function response(bool $success = true) 826 | { 827 | $response = [ 828 | 'success' => $success, 829 | 'endpoint' => $this->endpoint, 830 | 'method' => $this->method, 831 | 'code' => $this->responseCode, 832 | ]; 833 | 834 | if (!$success) { 835 | $response['error'] = $this->data['error']; 836 | } 837 | if (count($this->data)) { 838 | $response['data'] = $this->data; 839 | } 840 | 841 | // Set headers (including CORS) 842 | http_response_code($this->responseCode); 843 | header('Content-Type: application/json'); 844 | header('Access-Control-Allow-Origin: *'); 845 | header('Access-Control-Allow-Credentials: true'); 846 | header('Access-Control-Max-Age: 3600'); 847 | header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); 848 | 849 | echo json_encode($response); 850 | exit(0); 851 | } 852 | 853 | /** 854 | * Shortcut to generate a failure response for simple error message responses. 855 | * 856 | * @param string $message Message to return. 857 | * @param integer $code HTTP code to return. 858 | * @return void 859 | */ 860 | protected function error(string $message, int $code = 500) 861 | { 862 | $this->setResponseCode($code); 863 | $this->data['error'] = $message; 864 | $this->log->error($message, [ 865 | 'code' => $code, 866 | 'exception' => $this->data['exception'] ?? null 867 | ]); 868 | $this->response(false); 869 | } 870 | 871 | /** 872 | * Gets the root directory of the install path. 873 | * 874 | * @return string 875 | */ 876 | protected function rootDir(string $suffix = ''): string 877 | { 878 | $suffix = ltrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $suffix), '/\\'); 879 | 880 | return (str_replace(['/', '\\'], DIRECTORY_SEPARATOR, dirname(dirname(dirname(__DIR__))))) 881 | . (!empty($suffix) ? DIRECTORY_SEPARATOR . $suffix : ''); 882 | } 883 | 884 | /** 885 | * Gets the working directory. 886 | * 887 | * @return string 888 | */ 889 | protected function workDir(string $suffix = ''): string 890 | { 891 | $suffix = ltrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $suffix), '/\\'); 892 | 893 | return $this->rootDir('.wintercms' . (!empty($suffix) ? DIRECTORY_SEPARATOR . $suffix : '')); 894 | } 895 | 896 | /** 897 | * Gets the base URL for the current install. 898 | * 899 | * @return string 900 | */ 901 | protected function getBaseUrl(): string 902 | { 903 | if (isset($_SERVER['HTTP_HOST'])) { 904 | $baseUrl = !empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off' ? 'https' : 'http'; 905 | $baseUrl .= '://'. $_SERVER['HTTP_HOST']; 906 | $baseUrl .= str_replace(basename($_SERVER['SCRIPT_NAME']), '', $_SERVER['SCRIPT_NAME']); 907 | } else { 908 | $baseUrl = 'http://localhost/'; 909 | } 910 | 911 | return $baseUrl; 912 | } 913 | 914 | /** 915 | * Determines the default port number for the given database type. 916 | * 917 | * @param string $type 918 | * @return integer 919 | */ 920 | protected function getDefaultDbPort(string $type): int 921 | { 922 | switch ($type) { 923 | case 'mysql': 924 | return 3306; 925 | case 'pgsql': 926 | return 5432; 927 | case 'sqlsrv': 928 | return 1433; 929 | default: 930 | throw new \Exception('Invalid database type provided'); 931 | } 932 | } 933 | 934 | /** 935 | * Creates a database capsule. 936 | * 937 | * @param array $dbConfig 938 | * @return Capsule 939 | */ 940 | protected function createCapsule(array $dbConfig) 941 | { 942 | $capsule = new Capsule(); 943 | 944 | switch ($dbConfig['type']) { 945 | case 'sqlite': 946 | $capsule->addConnection([ 947 | 'driver' => $dbConfig['type'], 948 | 'database' => $this->rootDir('.temp.sqlite'), 949 | 'prefix' => '', 950 | ]); 951 | break; 952 | default: 953 | $capsule->addConnection([ 954 | 'driver' => $dbConfig['type'], 955 | 'host' => $dbConfig['host'] ?? null, 956 | 'port' => $dbConfig['port'] ?? $this->getDefaultDbPort($dbConfig['type']), 957 | 'database' => $dbConfig['name'], 958 | 'username' => $dbConfig['user'] ?? '', 959 | 'password' => $dbConfig['pass'] ?? '', 960 | 'charset' => ($dbConfig['type'] === 'mysql') ? 'utf8mb4' : 'utf8', 961 | 'collation' => ($dbConfig['type'] === 'mysql') ? 'utf8mb4_unicode_ci' : null, 962 | 'prefix' => '', 963 | ]); 964 | } 965 | 966 | return $capsule; 967 | } 968 | 969 | /** 970 | * Boots the Laravel framework for use in some installation steps. 971 | * 972 | * @return void 973 | */ 974 | protected function bootFramework() 975 | { 976 | $this->log->notice('Booting Laravel framework'); 977 | 978 | $autoloadFile = $this->workDir('bootstrap/autoload.php'); 979 | if (!file_exists($autoloadFile)) { 980 | $this->error('Unable to load bootstrap file for framework from "' . $autoloadFile . '".'); 981 | return; 982 | } 983 | 984 | $this->log->notice('Loading autoloader'); 985 | require $autoloadFile; 986 | 987 | $appFile = $this->workDir('bootstrap/app.php'); 988 | if (!file_exists($appFile)) { 989 | $this->error('Unable to load application initialization file for framework from "' . $appFile . '".'); 990 | return; 991 | } 992 | 993 | $this->log->notice('Bootstrapping kernel'); 994 | $app = require_once $appFile; 995 | $kernel = $app->make('Illuminate\Contracts\Console\Kernel'); 996 | $kernel->bootstrap(); 997 | } 998 | 999 | protected function apiRequest(string $method = 'GET', string $uri = '', array $params = []) 1000 | { 1001 | if (!in_array($method, ['GET', 'POST'])) { 1002 | throw new \Exception('Invalid method for API request, must be GET or POST'); 1003 | } 1004 | 1005 | $curl = $this->prepareRequest($method, $uri, $params); 1006 | $this->log->info('Winter API request', ['method' => $method, 'uri' => $uri]); 1007 | $response = curl_exec($curl); 1008 | 1009 | // Normalise line endings 1010 | $response = str_replace(["\r\n", "\n"], "\n", $response); 1011 | 1012 | // Parse response and code 1013 | $code = curl_getinfo($curl, CURLINFO_HTTP_CODE); 1014 | $contentType = explode('; ', curl_getinfo($curl, CURLINFO_CONTENT_TYPE))[0]; 1015 | $errored = false; 1016 | 1017 | if ($code === 0) { 1018 | $this->log->error('Unable to verify SSL certificate or connection', []); 1019 | $this->log->debug('Response received from Winter API', ['response' => $response]); 1020 | $errored = true; 1021 | 1022 | curl_close($curl); 1023 | 1024 | throw new SSLValidationException('Unable to verify SSL certificate or connection'); 1025 | } else if ($code < 200 || $code > 299) { 1026 | $this->log->error('HTTP code returned indicates an error', ['code' => $code]); 1027 | $this->log->debug('Response received from Winter API', ['response' => $response]); 1028 | $errored = true; 1029 | } 1030 | 1031 | // Parse JSON 1032 | if ($contentType === 'application/json' || $contentType === 'text/json') { 1033 | $response = json_decode($response, true); 1034 | 1035 | if (json_last_error() !== JSON_ERROR_NONE) { 1036 | $this->log->error('JSON data sent from server, but unable to parse', ['jsonError' => json_last_error_msg()]); 1037 | $this->log->debug('Response received from Winter API', ['response' => $response]); 1038 | $errored = true; 1039 | $response = 'Unable to parse JSON response from server'; 1040 | } 1041 | } 1042 | 1043 | curl_close($curl); 1044 | 1045 | if ($errored === true) { 1046 | throw new \Exception('An error occurred trying to communicate with the Winter CMS API: ' . $response); 1047 | } 1048 | 1049 | return $response; 1050 | } 1051 | 1052 | /** 1053 | * Prepares a cURL request to the Winter CMS API. 1054 | * 1055 | * @param string $method One of "GET", "POST" 1056 | * @param string $uri 1057 | * @param array $params 1058 | * @return \CurlHandle|resource 1059 | */ 1060 | protected function prepareRequest(string $method = 'GET', string $uri = '', array $params = []) 1061 | { 1062 | $curl = curl_init(); 1063 | 1064 | // Set default params 1065 | $params['protocol_version'] = '1.2'; 1066 | $params['client'] = 'winter-installer'; 1067 | $params['server'] = base64_encode(json_encode([ 1068 | 'php' => PHP_VERSION, 1069 | 'url' => $this->getBaseUrl(), 1070 | ])); 1071 | 1072 | curl_setopt_array($curl, [ 1073 | CURLOPT_RETURNTRANSFER => true, 1074 | CURLOPT_TIMEOUT => 300, 1075 | CURLOPT_FOLLOWLOCATION => true, 1076 | CURLOPT_MAXREDIRS => 5, 1077 | ]); 1078 | 1079 | if (file_exists($this->rootDir('.ignore-ssl'))) { 1080 | curl_setopt_array($curl, [ 1081 | CURLOPT_SSL_VERIFYPEER => false, 1082 | CURLOPT_SSL_VERIFYHOST => 0, 1083 | ]); 1084 | } else { 1085 | curl_setopt_array($curl, [ 1086 | CURLOPT_SSL_VERIFYPEER => true, 1087 | CURLOPT_SSL_VERIFYHOST => 2, 1088 | ]); 1089 | } 1090 | 1091 | if ($method === 'POST') { 1092 | curl_setopt($curl, CURLOPT_URL, self::API_URL . '/' . $uri); 1093 | curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($params)); 1094 | } else { 1095 | curl_setopt($curl, CURLOPT_URL, self::API_URL . '/' . $uri . '?' . http_build_query($params)); 1096 | } 1097 | 1098 | return $curl; 1099 | } 1100 | 1101 | /** 1102 | * Generates a cryptographically-secure key for encryption. 1103 | * 1104 | * @return string 1105 | */ 1106 | protected function generateKey(): string 1107 | { 1108 | $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFHIJKLMNOPQRSTUVWXYZ0123456789'; 1109 | $max = strlen($chars) - 1; 1110 | $key = ''; 1111 | 1112 | for ($i = 0; $i < 32; ++$i) { 1113 | $key .= substr($chars, random_int(0, $max), 1); 1114 | } 1115 | 1116 | return $key; 1117 | } 1118 | 1119 | /** 1120 | * PHP-based "rm -rf" command. 1121 | * 1122 | * Recursively removes a directory and all files and subdirectories within. 1123 | * 1124 | * @return void 1125 | */ 1126 | protected function rimraf(string $path) 1127 | { 1128 | if (!file_exists($path)) { 1129 | return; 1130 | } 1131 | 1132 | if (is_file($path)) { 1133 | @unlink($path); 1134 | return; 1135 | } 1136 | 1137 | $dir = new DirectoryIterator($path); 1138 | 1139 | foreach ($dir as $item) { 1140 | if ($item->isDot()) { 1141 | continue; 1142 | } 1143 | 1144 | if ($item->isDir()) { 1145 | $this->rimraf($item->getPathname()); 1146 | } 1147 | 1148 | @unlink($item->getPathname()); 1149 | } 1150 | 1151 | @rmdir($path); 1152 | } 1153 | 1154 | /** 1155 | * Register a custom exception handler for the API. 1156 | * 1157 | * @return void 1158 | */ 1159 | protected function setExceptionHandler() 1160 | { 1161 | set_exception_handler([$this, 'handleException']); 1162 | } 1163 | 1164 | /** 1165 | * Handle an uncaught PHP exception. 1166 | * 1167 | * @param \Exception $exception 1168 | * @return void 1169 | */ 1170 | public function handleException($exception) 1171 | { 1172 | $this->data['code'] = $exception->getCode(); 1173 | $this->data['file'] = $exception->getFile(); 1174 | $this->data['line'] = $exception->getLine(); 1175 | $this->log->error($exception->getMessage(), ['exception' => $exception]); 1176 | $this->error($exception->getMessage()); 1177 | } 1178 | } 1179 | --------------------------------------------------------------------------------