diff --git a/.env.example b/.env.example index 8369ce36..7a6559e5 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,19 @@ ADMIN_LTI_NAME="UDOIT 3 Admin" USE_DEVELOPMENT_AUTH="no" VERSION_NUMBER="3.5.0" +# Define which accessibility checker to use +# Available options: "phpally", "equalaccess_local", "equalaccess_lambda" +ACCESSIBILITY_CHECKER="equalaccess_lambda" + +# NOTE: When using a lambda function with equal access, +# you need to define the following in a separate .env.local: +# EQUALACCESS_AWS_ACCESS_KEY_ID= +# EQUALACCESS_AWS_SECRET_ACCESS_KEY= +# EQUALACCESS_AWS_REGION= +# EQUALACCESS_AWS_SERVICE= +# EQUALACCESS_AWS_HOST=abcdefghi.execute-api.us-east-1.amazonaws.com +# EQUALACCESS_CANONICAL_URI=endpoint/generate-accessibility-report + ###> symfony/messenger ### MESSENGER_TRANSPORT_DSN=doctrine://default diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df80c8c6..8025c049 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: - main - 'stable/*' + - 'equal-access' env: REGISTRY: ghcr.io diff --git a/assets/js/Components/Constants.js b/assets/js/Components/Constants.js index b1c8e155..06c880cc 100644 --- a/assets/js/Components/Constants.js +++ b/assets/js/Components/Constants.js @@ -36,5 +36,8 @@ export const issueRuleIds = [ "VideoEmbedCheck", "VideoProvidesCaptions", "VideosEmbeddedOrLinkedNeedCaptions", - "VideosHaveAutoGeneratedCaptions" + "VideosHaveAutoGeneratedCaptions", + + "img_alt_misuse", + "text_contrast_sufficient", ] diff --git a/assets/js/Components/Forms/ContrastForm.js b/assets/js/Components/Forms/ContrastForm.js index 14b2a016..df91e693 100644 --- a/assets/js/Components/Forms/ContrastForm.js +++ b/assets/js/Components/Forms/ContrastForm.js @@ -316,7 +316,12 @@ export default class ContrastForm extends React.Component { if (element.style.backgroundColor) { return Contrast.standardizeColor(element.style.backgroundColor) - } + } + else if (metadata.messageArgs) { + // TODO: check if 4th argument exists + // (Equal Access) text_contrast_sufficient: The 4th index in messageArgs is the background color + return metadata.messageArgs[4] + } else { return (metadata.backgroundColor) ? Contrast.standardizeColor(metadata.backgroundColor) : this.props.settings.backgroundColor } @@ -332,6 +337,10 @@ export default class ContrastForm extends React.Component { if (element.style.color) { return Contrast.standardizeColor(element.style.color) } + else if (metadata.messageArgs) { + // (Equal Access) text_contrast_sufficient: The 3rd index in messageArgs is the foreground color + return metadata.messageArgs[3] + } else { return (metadata.color) ? Contrast.standardizeColor(metadata.color) : this.props.settings.textColor } diff --git a/assets/js/Components/Forms/LabelForm.js b/assets/js/Components/Forms/LabelForm.js new file mode 100644 index 00000000..00e6c9cf --- /dev/null +++ b/assets/js/Components/Forms/LabelForm.js @@ -0,0 +1,151 @@ +import React, { useEffect, useState } from 'react' +import { View } from '@instructure/ui-view' +import { TextInput } from '@instructure/ui-text-input' +import { Button } from '@instructure/ui-buttons' +import { IconCheckMarkLine } from '@instructure/ui-icons' +import { Checkbox } from '@instructure/ui-checkbox' +import { Spinner } from '@instructure/ui-spinner' +import * as Html from '../../Services/Html' + +export default function LabelForm(props) { + + let html = props.activeIssue.newHtml ? props.activeIssue.newHtml : props.activeIssue.sourceHtml + + if (props.activeIssue.status === '1') { + html = props.activeIssue.newHtml + } + + let element = Html.toElement(html) + + const [textInputValue, setTextInputValue] = useState(element ? Html.getAttribute(element, "aria-label") : "") + // const [deleteLabel, setDeleteLabel] = useState(!element && (props.activeIssue.status === "1")) + const [textInputErrors, setTextInputErrors] = useState([]) + + let formErrors = [] + + useEffect(() => { + let html = props.activeIssue.newHtml ? props.activeIssue.newHtml : props.activeIssue.sourceHtml + if (props.activeIssue.status === 1) { + html = props.activeIssue.newHtml + } + + let element = Html.toElement(html) + setTextInputValue(element ? Html.getAttribute(element, "aria-label") : "") + // setDeleteLabel(!element && props.activeIssue.status === 1) + + formErrors = [] + + }, [props.activeIssue]) + + useEffect(() => { + handleHtmlUpdate() + }, [textInputValue]) + + const handleHtmlUpdate = () => { + let updatedElement = Html.toElement(html) + + updatedElement = Html.setAttribute(updatedElement, "aria-label", textInputValue) + + // if (deleteLabel) { + // updatedElement = Html.removeAttribute(updatedElement, "aria-label") + // } + // else { + // updatedElement = Html.setAttribute(updatedElement, "aria-label", textInputValue) + // } + + let issue = props.activeIssue + issue.newHtml = Html.toString(updatedElement) + props.handleActiveIssue(issue) + + } + + const handleButton = () => { + formErrors = [] + + // if (!deleteLabel) { + // checkTextNotEmpty() + // } + + checkTextNotEmpty() + checkLabelIsUnique() + + if (formErrors.length > 0) { + setTextInputErrors(formErrors) + } + else { + props.handleIssueSave(props.activeIssue) + } + } + + const handleInput = (event) => { + setTextInputValue(event.target.value) + // handleHtmlUpdate() + } + + // const handleCheckbox = () => { + // setDeleteLabel(!deleteLabel) + // // handleHtmlUpdate() + // } + + const checkTextNotEmpty = () => { + const text = textInputValue.trim().toLowerCase() + if (text === '') { + formErrors.push({ text: "Empty label text.", type: "error" }) + } + } + + const checkLabelIsUnique = () => { + // in the case of aria_*_label_unique, messageArgs (from metadata) should have the offending label (at the first index) + // i guess we could get it from the aria-label itself as well... + const issue = props.activeIssue + const metadata = issue.metadata ? JSON.parse(issue.metadata) : {} + const labelFromMessageArgs = metadata.messageArgs ? metadata.messageArgs[0] : null + const text = textInputValue.trim().toLowerCase() + + if (labelFromMessageArgs) { + if (text == labelFromMessageArgs) { + formErrors.push({ text: "Cannot reuse label text.", type: "error" }) + } + } + + } + + const pending = props.activeIssue && props.activeIssue.pending == "1" + const buttonLabel = pending ? "form.processing" : "form.submit" + + return ( + + + + + {/* + + + + */} + + + + + ); +} \ No newline at end of file diff --git a/assets/js/Components/Forms/QuoteForm.js b/assets/js/Components/Forms/QuoteForm.js new file mode 100644 index 00000000..37b956b9 --- /dev/null +++ b/assets/js/Components/Forms/QuoteForm.js @@ -0,0 +1,166 @@ +import React, { useEffect, useState } from 'react' +import { View } from '@instructure/ui-view' +import { TextInput } from '@instructure/ui-text-input' +import { Button } from '@instructure/ui-buttons' +import { IconCheckMarkLine } from '@instructure/ui-icons' +import { Checkbox } from '@instructure/ui-checkbox' +import { Spinner } from '@instructure/ui-spinner' +import * as Html from '../../Services/Html' +import { SimpleSelect } from '@instructure/ui-simple-select' + +// TODO: not finished + +export default function QuoteForm(props) { + + let html = props.activeIssue.newHtml ? props.activeIssue.newHtml : props.activeIssue.sourceHtml + + if (props.activeIssue.status === '1') { + html = props.activeIssue.newHtml + } + + let element = Html.toElement(html) + + const [textInputValue, setTextInputValue] = useState(element ? Html.getAttribute(element, "aria-label") : "") + const [deleteQuotes, setRemoveQuotes] = useState(!element && (props.activeIssue.status === "1")) + const [textInputErrors, setTextInputErrors] = useState([]) + + let formErrors = [] + + useEffect(() => { + let html = props.activeIssue.newHtml ? props.activeIssue.newHtml : props.activeIssue.sourceHtml + if (props.activeIssue.status === 1) { + html = props.activeIssue.newHtml + } + + let element = Html.toElement(html) + setTextInputValue(element ? Html.getAttribute(element, "aria-label") : "") + // setRemoveQuotes(!element && props.activeIssue.status === 1) + + formErrors = [] + + }, [props.activeIssue]) + + useEffect(() => { + handleHtmlUpdate() + }, [textInputValue]) + + const handleHtmlUpdate = () => { + let updatedElement = Html.toElement(html) + + updatedElement = Html.setAttribute(updatedElement, "aria-label", textInputValue) + + // if (deleteQuotes) { + // updatedElement = Html.removeAttribute(updatedElement, "aria-label") + // } + // else { + // updatedElement = Html.setAttribute(updatedElement, "aria-label", textInputValue) + // } + + let issue = props.activeIssue + issue.newHtml = Html.toString(updatedElement) + props.handleActiveIssue(issue) + + } + + const handleButton = () => { + formErrors = [] + + // if (!deleteQuotes) { + // checkTextNotEmpty() + // } + + checkTextNotEmpty() + checkLabelIsUnique() + + if (formErrors.length > 0) { + setTextInputErrors(formErrors) + } + else { + props.handleIssueSave(props.activeIssue) + } + } + + const handleInput = (event) => { + setTextInputValue(event.target.value) + // handleHtmlUpdate() + } + + const handleCheckbox = () => { + setRemoveQuotes(!deleteQuotes) + // handleHtmlUpdate() + } + + const checkTextNotEmpty = () => { + const text = textInputValue.trim().toLowerCase() + if (text === '') { + formErrors.push({ text: "Empty label text.", type: "error" }) + } + } + + const checkLabelIsUnique = () => { + // in the case of aria_*_label_unique, messageArgs (from metadata) should have the offending label (at the first index) + // i guess we could get it from the aria-label itself as well... + const issue = props.activeIssue + const metadata = issue.metadata ? JSON.parse(issue.metadata) : {} + const labelFromMessageArgs = metadata.messageArgs ? metadata.messageArgs[0] : null + const text = textInputValue.trim().toLowerCase() + + if (labelFromMessageArgs) { + if (text == labelFromMessageArgs) { + formErrors.push({ text: "Cannot reuse label text.", type: "error" }) + } + } + + } + + const pending = props.activeIssue && props.activeIssue.pending == "1" + const buttonLabel = pending ? "form.processing" : "form.submit" + + // TODO: use props.t (from en/es.json) to display text for renderLabel, etc + return ( + + + + + -- Choose -- + + + + + + + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/assets/js/Components/Forms/StyleMisuseForm.js b/assets/js/Components/Forms/StyleMisuseForm.js new file mode 100644 index 00000000..ace162bb --- /dev/null +++ b/assets/js/Components/Forms/StyleMisuseForm.js @@ -0,0 +1,183 @@ +import React, { act, useEffect, useState } from 'react' +import { View } from '@instructure/ui-view' +import { TextInput } from '@instructure/ui-text-input' +import { Button } from '@instructure/ui-buttons' +import { IconCheckMarkLine } from '@instructure/ui-icons' +import { Checkbox } from '@instructure/ui-checkbox' +import { Spinner } from '@instructure/ui-spinner' +import * as Html from '../../Services/Html' +import * as Contrast from '../../Services/Contrast' + +export default function StyleMisuseForm(props) { + const [useBold, setUseBold] = useState(isBold()) + const [useItalics, setUseItalics] = useState(isItalicized()) + const [removeStyling, setRemoveStyling] = useState(false) + const [checkBoxErrors, setCheckBoxErrors] = useState([]) + const [styleAttribute, setStyleAttribute] = useState(Html.getAttribute(Html.getIssueHtml(props.activeIssue), "style")) + + console.log(styleAttribute) + + let formErrors = [] + + useEffect(() => { + updatePreview() + }, []) + + useEffect(() => { + setUseBold(isBold()) + setUseItalics(isItalicized()) + setCheckBoxErrors([]) + + formErrors = [] + }, [props.activeIssue]) + + useEffect(() => { + updatePreview() + }, [useBold, useItalics, removeStyling]) + + function handleBoldToggle() { + setUseBold(!useBold) + updatePreview() + } + + function handleItalicsToggle() { + setUseItalics(!useItalics) + updatePreview() + } + + function handleStyleToggle() { + setRemoveStyling(!removeStyling) + console.log("style tag:") + console.log(styleAttribute) + updatePreview() + } + + function handleSubmit() { + let issue = props.activeIssue + + if (cssEmphasisIsValid(issue)) { + let issue = props.activeIssue + issue.newHtml = Contrast.convertHtmlRgb2Hex(issue.newHtml) + props.handleIssueSave(issue) + } + else { + // push errors + formErrors = [] + formErrors.push({ text: `${props.t('form.contrast.must_select')}` , type: 'error' }) + + setCheckBoxErrors(formErrors) + } + } + + function processHtml(html) { + let element = Html.toElement(html) + + // Clean up tags + Html.removeTag(element, 'strong') + Html.removeTag(element, 'em') + + element.innerHTML = (useBold) ? `${element.innerHTML}` : element.innerHTML + element.innerHTML = (useItalics) ? `${element.innerHTML}` : element.innerHTML + + if (removeStyling) { + Html.removeAttribute(element, "style") + } + else { + Html.setAttribute(element, "style", styleAttribute) + } + + return Html.toString(element) + } + + function updatePreview() { + let issue = props.activeIssue + const html = Html.getIssueHtml(props.activeIssue) + + issue.newHtml = processHtml(html) + props.handleActiveIssue(issue) + } + + function isBold() { + const issue = props.activeIssue + const metadata = (issue.metadata) ? JSON.parse(issue.metadata) : {} + const html = Html.getIssueHtml(props.activeIssue) + const element = Html.toElement(html) + + return ((Html.hasTag(element, 'strong')) || (metadata.fontWeight === 'bold')) + } + + function isItalicized() { + const issue = props.activeIssue + const metadata = (issue.metadata) ? JSON.parse(issue.metadata) : {} + const html = Html.getIssueHtml(props.activeIssue) + const element = Html.toElement(html) + + return ((Html.hasTag(element, 'em')) || (metadata.fontStyle == 'italic')) + } + + function hasStyleTag() { + const html = Html.getIssueHtml(props.activeIssue) + const element = Html.toElement(html) + + console.log("checking style attribute") + console.log(Html.getAttribute(element, "style")) + + return true + + // return (Html.getAttribute(element, "style") != null) + } + + function cssEmphasisIsValid(issue) { + if (issue.scanRuleId === 'style_color_misuse') { + if (!useBold && !useItalics) { + return false + } + } + return true + } + + const pending = (props.activeIssue && (props.activeIssue.pending == '1')) + const buttonLabel = (pending) ? 'form.processing' : 'form.submit' + + return ( + + + + + + + + + + + + + + {/* TOOD: use props.t */} + + + + + + + {props.activeIssue.recentlyUpdated && + + + {props.t('label.fixed')} + + } + + + ) +} \ No newline at end of file diff --git a/assets/js/Components/Forms/UfixitReviewOnly.js b/assets/js/Components/Forms/UfixitReviewOnly.js index 17c88fbc..513b973b 100644 --- a/assets/js/Components/Forms/UfixitReviewOnly.js +++ b/assets/js/Components/Forms/UfixitReviewOnly.js @@ -1,19 +1,31 @@ -import React from 'react' +import React, { useEffect, useState } from 'react' import { View } from '@instructure/ui-view' import { Text } from '@instructure/ui-text' -class UfixitReviewOnly extends React.Component { - constructor(props) { - super(props) - } +export default function UfixitReviewOnly(props) { + function getMetadata() { + const issue = props.activeIssue + const metadata = issue.metadata ? JSON.parse(issue.metadata) : {} + return metadata + } + + const metadata = getMetadata() + const [message, setMessage] = useState("") - render() { - return ( - - {this.props.t('label.review_only')} - - ) + useEffect(() => { + if (metadata.message) { + // when using equal access, we should have metadata.message + setMessage(metadata.message) + } + else { + // otherwise, in phpally, we display the default "review" text + setMessage(props.t("label.review_only")) } -} + }, [metadata]) -export default UfixitReviewOnly \ No newline at end of file + return ( + + {message} + + ) +} \ No newline at end of file diff --git a/assets/js/Services/Ufixit.js b/assets/js/Services/Ufixit.js index 5bef0031..5647ad21 100644 --- a/assets/js/Services/Ufixit.js +++ b/assets/js/Services/Ufixit.js @@ -8,8 +8,12 @@ import TableHeaders from '../Components/Forms/TableHeaders' import Video from '../Components/Forms/Video' import LinkForm from '../Components/Forms/LinkForm' import EmphasisForm from '../Components/Forms/EmphasisForm' +import LabelForm from '../Components/Forms/LabelForm' +import QuoteForm from '../Components/Forms/QuoteForm' +import StyleMisuseForm from '../Components/Forms/StyleMisuseForm' const UfixitForms = { + // phpAlly rules AnchorMustContainText: AnchorText, AnchorSuspiciousLinkText: AnchorText, BrokenLink: LinkForm, @@ -29,6 +33,16 @@ const UfixitForms = { VideoCaptionsMatchCourseLanguage: Video, VideosEmbeddedOrLinkedNeedCaptions: Video, VideosHaveAutoGeneratedCaptions: Video, + + // Equal Access Rules + img_alt_misuse: AltText, + aria_application_labelled: LabelForm, + aria_application_label_unique: LabelForm, + text_contrast_sufficient: ContrastForm, + text_block_heading: HeadingStyleForm, + heading_content_exists: HeadingEmptyForm, + // text_quoted_correctly: QuoteForm, + style_color_misuse: StyleMisuseForm, } export function returnIssueForm(activeIssue) { diff --git a/composer.json b/composer.json index 9574c597..0b094d75 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "ext-ctype": "*", "ext-iconv": "*", "ext-sodium": "*", + "aws/aws-sdk-php": "^3.324", "composer/package-versions-deprecated": "1.11.99.3", "doctrine/doctrine-bundle": "^2.4", "doctrine/doctrine-migrations-bundle": "^3.1", @@ -24,6 +25,7 @@ "phpdocumentor/reflection-docblock": "^5.2", "phpstan/phpdoc-parser": "^0.5.3", "sensio/framework-extra-bundle": "^6.2", + "sunra/php-simple-html-dom-parser": "1.5.2", "symfony/apache-pack": "^1.0", "symfony/asset": "^6.4", "symfony/console": "^6.4", diff --git a/composer.lock b/composer.lock index d5db17cb..4c865b58 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,160 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "25659e2a281864cc687b568968c9b7c2", + "content-hash": "b3ccfa582029cdf621ca1e255bdbcde8", "packages": [ + { + "name": "aws/aws-crt-php", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/awslabs/aws-crt-php.git", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7" + }, + "time": "2024-10-18T22:15:13+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.336.11", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "442039c766a82f06ecfecb0ac2c610d6aaba228d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/442039c766a82f06ecfecb0ac2c610d6aaba228d", + "reference": "442039c766a82f06ecfecb0ac2c610d6aaba228d", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.2.3", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", + "guzzlehttp/promises": "^1.4.0 || ^2.0", + "guzzlehttp/psr7": "^1.9.1 || ^2.4.5", + "mtdowling/jmespath.php": "^2.6", + "php": ">=7.2.5", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^1.10.22", + "dms/phpunit-arraysubset-asserts": "^0.4.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-pcntl": "*", + "ext-sockets": "*", + "nette/neon": "^2.3", + "paragonie/random_compat": ">= 2", + "phpunit/phpunit": "^5.6.3 || ^8.5 || ^9.5", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", + "sebastian/comparator": "^1.2.3 || ^4.0", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + }, + "exclude-from-classmap": [ + "src/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "http://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "http://aws.amazon.com/sdkforphp", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "support": { + "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", + "issues": "https://github.com/aws/aws-sdk-php/issues", + "source": "https://github.com/aws/aws-sdk-php/tree/3.336.11" + }, + "time": "2025-01-08T19:06:59+00:00" + }, { "name": "composer/package-versions-deprecated", "version": "1.11.99.3", @@ -2710,6 +2862,72 @@ }, "time": "2023-05-03T06:19:36+00:00" }, + { + "name": "mtdowling/jmespath.php", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.33" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.8-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.8.0" + }, + "time": "2024-09-04T18:46:31+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.12.1", @@ -3706,6 +3924,58 @@ ], "time": "2024-12-10T13:12:19+00:00" }, + { + "name": "sunra/php-simple-html-dom-parser", + "version": "v1.5.2", + "source": { + "type": "git", + "url": "https://github.com/sunra/php-simple-html-dom-parser.git", + "reference": "75b9b1cb64502d8f8c04dc11b5906b969af247c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sunra/php-simple-html-dom-parser/zipball/75b9b1cb64502d8f8c04dc11b5906b969af247c6", + "reference": "75b9b1cb64502d8f8c04dc11b5906b969af247c6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=5.3.2" + }, + "type": "library", + "autoload": { + "psr-0": { + "Sunra\\PhpSimple\\HtmlDomParser": "Src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sunra", + "email": "sunra@yandex.ru", + "homepage": "https://github.com/sunra" + }, + { + "name": "S.C. Chen", + "homepage": "http://sourceforge.net/projects/simplehtmldom/" + } + ], + "description": "Composer adaptation of: A HTML DOM parser written in PHP5+ let you manipulate HTML in a very easy way! Require PHP 5+. Supports invalid HTML. Find tags on an HTML page with selectors just like jQuery. Extract contents from HTML in a single line.", + "homepage": "https://github.com/sunra/php-simple-html-dom-parser", + "keywords": [ + "dom", + "html", + "parser" + ], + "support": { + "issues": "https://github.com/sunra/php-simple-html-dom-parser/issues", + "source": "https://github.com/sunra/php-simple-html-dom-parser/tree/master" + }, + "time": "2016-11-22T22:57:47+00:00" + }, { "name": "symfony/apache-pack", "version": "v1.0.1", @@ -11831,7 +12101,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -11840,6 +12110,6 @@ "ext-iconv": "*", "ext-sodium": "*" }, - "platform-dev": {}, + "platform-dev": [], "plugin-api-version": "2.6.0" } diff --git a/composer.phar b/composer.phar new file mode 100755 index 00000000..15c4a208 Binary files /dev/null and b/composer.phar differ diff --git a/docker-compose.nginx.yml b/docker-compose.nginx.yml index bb3a6fcd..71ae63be 100644 --- a/docker-compose.nginx.yml +++ b/docker-compose.nginx.yml @@ -68,7 +68,7 @@ services: bash -c ' cd /app && yarn install && - yarn build' + yarn build --watch' volumes: web: diff --git a/src/Controller/IssuesController.php b/src/Controller/IssuesController.php index c003334a..644f785f 100644 --- a/src/Controller/IssuesController.php +++ b/src/Controller/IssuesController.php @@ -5,6 +5,7 @@ use App\Entity\Issue; use App\Response\ApiResponse; use App\Services\LmsPostService; +use App\Services\EqualAccessService; use App\Services\PhpAllyService; use App\Services\UtilityService; use Doctrine\Persistence\ManagerRegistry; @@ -157,13 +158,15 @@ public function markAsReviewed(Request $request, LmsPostService $lmsPost, Utilit } // Rescan an issue in PhpAlly + // TODO: implement equal access into this #[Route('/api/issues/{issue}/scan', name: 'scan_issue')] - public function scanIssue(Issue $issue, PhpAllyService $phpAlly, UtilityService $util) + public function scanIssue(Issue $issue, PhpAllyService $phpAlly, UtilityService $util, EqualAccessService $equalAccess) { $apiResponse = new ApiResponse(); $issueRule = 'CidiLabs\\PhpAlly\\Rule\\'.$issue->getScanRuleId(); $report = $phpAlly->scanHtml($issue->getHtml(), [$issueRule], $issue->getContentItem()->getCourse()->getInstitution()); + // $equalAccess->logToServer("scanIssue in issuescontroller"); $reportIssues = $report->getIssues(); $reportErrors = $report->getErrors(); diff --git a/src/Controller/SyncController.php b/src/Controller/SyncController.php index 307201d7..ed184886 100644 --- a/src/Controller/SyncController.php +++ b/src/Controller/SyncController.php @@ -9,6 +9,8 @@ use App\Services\LmsApiService; use App\Services\LmsFetchService; use App\Services\PhpAllyService; +use App\Services\EqualAccessService; +use App\Services\ScannerService; use App\Services\UtilityService; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Routing\Annotation\Route; @@ -125,7 +127,7 @@ public function fullCourseRescan(Course $course, LmsFetchService $lmsFetch) { } #[Route('/api/sync/content/{contentItem}', name: 'content_sync', methods: ['GET'])] - public function requestContentSync(ContentItem $contentItem, LmsFetchService $lmsFetch, PhpAllyService $phpAlly) + public function requestContentSync(ContentItem $contentItem, LmsFetchService $lmsFetch, ScannerService $scanner) { $response = new ApiResponse(); $course = $contentItem->getCourse(); @@ -135,10 +137,10 @@ public function requestContentSync(ContentItem $contentItem, LmsFetchService $lm $lmsFetch->deleteContentItemIssues(array($contentItem)); // Rescan the contentItem - $phpAllyReport = $phpAlly->scanContentItem($contentItem); + $report = $scanner->scanContentItem($contentItem, null, $this->util); // Add rescanned Issues to database - foreach ($phpAllyReport->getIssues() as $issue) { + foreach ($report->getIssues() as $issue) { // Create issue entity $lmsFetch->createIssue($issue, $contentItem); } diff --git a/src/Services/AsyncEqualAccessReport.php b/src/Services/AsyncEqualAccessReport.php new file mode 100644 index 00000000..9964a183 --- /dev/null +++ b/src/Services/AsyncEqualAccessReport.php @@ -0,0 +1,247 @@ +loadConfig(); + } + + private function loadConfig() { + // Load variables for AWS + $this->awsAccessKeyId = $_ENV['EQUALACCESS_AWS_ACCESS_KEY_ID']; + $this->awsSecretAccessKey = $_ENV['EQUALACCESS_AWS_SECRET_ACCESS_KEY']; + $this->awsRegion = $_ENV['EQUALACCESS_AWS_REGION']; + $this->host = $_ENV['EQUALACCESS_AWS_HOST']; + $this->canonicalUri = $_ENV['EQUALACCESS_AWS_CANONICAL_URI']; + $this->endpoint = "https://{$this->host}/{$this->canonicalUri}"; + } + + public function logToServer(string $message) { + $options = [ + 'http' => [ + 'header' => "Content-type: text/html\r\n", + 'method' => 'POST', + 'content' => $message, + ], + ]; + + $context = stream_context_create($options); + file_get_contents("http://host.docker.internal:3000/log", false, $context); + } + + public function sign(RequestInterface $request): RequestInterface { + $signature = new SignatureV4('execute-api', $this->awsRegion); + $credentials = new Credentials($this->awsAccessKeyId, $this->awsSecretAccessKey); + + return $signature->signRequest($request, $credentials); + } + + public function createRequest($payload) { + return new Request( + "POST", + "{$this->endpoint}", + [ + "Content-Type" => "application/json", + ], + $payload, + ); + } + + public function postMultipleArrayAsync(array $contentItems): array { + $promises = []; + $contentItemsReport = []; + + $client = new Client(); + + // Combine every pages into a request + $htmlArray = []; + $counter = 0; + $payloadSize = 5; + foreach ($contentItems as $contentItem) { + if ($counter >= $payloadSize) { + // Reached our counter limit, create a new payload + // and create and sign a request that we send to the lambda function + $payload = json_encode(["html" => $htmlArray]); + + $request = $this->createRequest($payload); + $signedRequest = $this->sign($request); + + $promises[] = $client->sendAsync($signedRequest); + $counter = 0; + + $htmlArray = []; + } + + // Get the HTML then clean up and push a page into an array + $html = $contentItem->getBody(); + $document = $this->getDomDocument($html)->saveHTML(); + array_push($htmlArray, $document); + + $counter++; + } + + // Send out any leftover pages we might have + if (count($htmlArray) > 0) { + $payload = json_encode(["html" => $htmlArray]); + + $request = $this->createRequest($payload); + $signedRequest = $this->sign($request); + + $promises[] = $client->sendAsync($signedRequest); + } + + // $this->logToServer("Number of promises:"); + // $this->logToServer(count($promises)); + + $results = Promise\Utils::settle($promises)->wait(); + // $results = Promise\Utils::unwrap($promises); + + $errors = 0; + + foreach ($results as $result) { + // Every "block" of reports pages should be in a stringified + // JSON, so we need to decode the JSON to be able to iterate through + // it first.} + + if (isset($result["value"])) { + $response = json_decode($result["value"]->getBody()->getContents(), true); + } + else if (isset($result["reason"])) { + $errors++; + } + + // $this->logToServer($result["value"]->getBody()->getContents()); + + foreach ($response as $report) { + // $this->logToServer(json_encode($report)); + $contentItemsReport[] = $report; + } + } + + // $this->logToServer("Number of errors:"); + // $this->logToServer($errors); + + return $contentItemsReport; + } + + + public function postMultipleAsync(array $contentItems): array { + $promises = []; + + $client = new Client(); + + // Iterate through each scannable Canvas page and add a new + // POST request to our array of promises + foreach ($contentItems as $contentItem) { + // $this->logToServer("Checking: {$contentItem->getTitle()}"); + // Clean up the content item's HTML document + // then create a payload that we'll send to the lambda function + $html = $contentItem->getBody(); + $document = $this->getDomDocument($html)->saveHTML(); + + $payload = json_encode(["html" => $document]); + $request = $this->createRequest($payload); + $signedRequest = $this->sign($request); + + $promises[] = $client->sendAsync($signedRequest); + } + + // Wait for all the POSTs to resolve and save them into an array + // Each promise is resolved into an array with a "state" key (fulfilled/rejected) and "value" (the JSON) + $results = Promise\Utils::unwrap($promises); + + // Save the report for the content item into an array. + // They should (in theory) be in the same order they were sent in. + foreach ($results as $result) { + $response = $result->getBody()->getContents(); + $json = json_decode($response, true); + // $this->logToServer(json_encode($json, JSON_PRETTY_PRINT)); + // $this->logToServer("Saving to contentItemsReport..."); + $contentItemsReport[] = $json; + } + + return $contentItemsReport; + } + + // Scan a single content item + public function postSingleAsync(ContentItem $contentItem) { + $client = new Client(); + $report = null; + + // Clean up the content item's HTML document + // and create a payload to send + $html = $contentItem->getBody(); + $document = $this->getDomDocument($html)->saveHTML(); + $payload = json_encode(["html" => $document]); + + $request = $this->createRequest($payload); + $signedRequest = $this->sign($request); + + // POST document to Lambda and wait for fulfillment + // $this->logToServer("Sending to single promise..."); + $promise = $client->sendAsync($signedRequest); + $response = $promise->wait(); + + if ($response) { + // $this->logToServer("Fulfilled!"); + + $contents = $response->getBody()->getContents(); + $report = json_decode($contents, true)[0]; + } + + // Return the Equal Access report + return $report; + } + + public function getDomDocument($html) + { + // Load the HTML string into a DOMDocument that PHP can parse. + // TODO: checks for if , , or and