From 0a735551746a540c391d1b9c4d1e7ef1e68de3ae Mon Sep 17 00:00:00 2001 From: silentsilas Date: Sat, 5 Oct 2024 18:04:29 -0400 Subject: [PATCH] get import maps working, add JSDoc ish to modules, let EJS import them --- .eslintignore | 1 + .gitignore | 2 + package-lock.json | 202 +++++++++++++++++++++++++++++++++++++ package.json | 5 +- static/js/send.js | 110 ++++++++++++++++++++ static/js/utils.js | 46 +++++++++ tsconfig.json | 4 +- views/app/send.ejs | 228 +++++++++++------------------------------- views/layout/head.ejs | 2 +- 9 files changed, 428 insertions(+), 172 deletions(-) create mode 100644 static/js/send.js create mode 100644 static/js/utils.js diff --git a/.eslintignore b/.eslintignore index 86fe674..3701152 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,3 +3,4 @@ processes.config.js node_modules coverage vitest.config.mts +es-module-shims.js diff --git a/.gitignore b/.gitignore index a553f34..d5a7097 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,5 @@ test/**/*.js.map .envrc database.sqlite + +es-module-shims.js diff --git a/package-lock.json b/package-lock.json index ed5687a..0f1c460 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "intended-server", "version": "1.0.0", + "hasInstallScript": true, "dependencies": { "@tsed/ajv": "^7.83.0", "@tsed/common": "^7.83.0", @@ -41,6 +42,7 @@ "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "dotenv-flow": "^4.1.0", + "es-module-shims": "^1.10.0", "express": "^4.20.0", "express-session": "^1.18.0", "method-override": "^3.0.0", @@ -76,6 +78,7 @@ "@vitest/coverage-v8": "^2.0.5", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", + "eslint-plugin-jsdoc": "^50.3.1", "eslint-plugin-prettier": "^5.2.1", "prettier": "^3.3.3", "rimraf": "^6.0.1", @@ -182,6 +185,21 @@ "node": ">=12" } }, + "node_modules/@es-joy/jsdoccomment": { + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.48.0.tgz", + "integrity": "sha512-G6QUWIcC+KvSwXNsJyDTHvqUdNoAVJPPgkc3+Uk4WBKqZvoXhlvazOgm9aL0HwihJLQf0l+tOE2UFzXBqCqgDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "comment-parser": "1.4.1", + "esquery": "^1.6.0", + "jsdoc-type-pratt-parser": "~4.1.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -3434,6 +3452,16 @@ "license": "ISC", "optional": true }, + "node_modules/are-docs-informative": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", + "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/are-we-there-yet": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", @@ -4136,6 +4164,16 @@ "node": ">= 0.8" } }, + "node_modules/comment-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", + "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -4765,6 +4803,19 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", + "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-shims": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/es-module-shims/-/es-module-shims-1.10.0.tgz", + "integrity": "sha512-3PmuShQBd9d8pulTFx6L7HKgncnZ1oeSSbrEfnUasb3Tv974BAvyFtW1HLPJSkh5fCaU9JNZbBzPdbxSwg2zqA==", + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -4897,6 +4948,101 @@ "eslint": ">=7.0.0" } }, + "node_modules/eslint-plugin-jsdoc": { + "version": "50.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.3.1.tgz", + "integrity": "sha512-SY9oUuTMr6aWoJggUS40LtMjsRzJPB5ZT7F432xZIHK3EfHF+8i48GbUBpwanrtlL9l1gILNTHK9o8gEhYLcKA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@es-joy/jsdoccomment": "~0.48.0", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.1", + "debug": "^4.3.6", + "escape-string-regexp": "^4.0.0", + "espree": "^10.1.0", + "esquery": "^1.6.0", + "parse-imports": "^2.1.1", + "semver": "^7.6.3", + "spdx-expression-parse": "^4.0.0", + "synckit": "^0.9.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/eslint-visitor-keys": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", + "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/espree": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz", + "integrity": "sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.12.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint-plugin-prettier": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", @@ -6540,6 +6686,16 @@ "license": "MIT", "optional": true }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz", + "integrity": "sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -7488,6 +7644,20 @@ "node": ">=6" } }, + "node_modules/parse-imports": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/parse-imports/-/parse-imports-2.2.1.tgz", + "integrity": "sha512-OL/zLggRp8mFhKL0rNORUTR4yBYujK/uU+xZL+/0Rgm2QE4nLO9v8PzEweSJEbMGKmDRjJE4R3IMJlL2di4JeQ==", + "dev": true, + "license": "Apache-2.0 AND MIT", + "dependencies": { + "es-module-lexer": "^1.5.3", + "slashes": "^3.0.12" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", @@ -8731,6 +8901,13 @@ "node": ">=8" } }, + "node_modules/slashes": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/slashes/-/slashes-3.0.12.tgz", + "integrity": "sha512-Q9VME8WyGkc7pJf6QEkj3wE+2CnvZMI+XJhwdTPR8Z/kWQRXi7boAWLDibRPyHRTUTPx5FaU7MsyrjI3yLB4HA==", + "dev": true, + "license": "ISC" + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -8836,6 +9013,31 @@ "source-map": "^0.6.0" } }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.20", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.20.tgz", + "integrity": "sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", diff --git a/package.json b/package.json index e18442d..43678c5 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "typeorm": "typeorm-ts-node-commonjs", "test:lint": "eslint '**/*.{ts,js}'", "test:lint:fix": "eslint '**/*.{ts,js}' --fix", - "prettier": "prettier '**/*.{ts,js,json,md,yml,yaml}' --write" + "prettier": "prettier '**/*.{ts,js,json,md,yml,yaml}' --write", + "postinstall": "cp node_modules/es-module-shims/dist/es-module-shims.js static/" }, "dependencies": { "@tsed/ajv": "^7.83.0", @@ -50,6 +51,7 @@ "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "dotenv-flow": "^4.1.0", + "es-module-shims": "^1.10.0", "express": "^4.20.0", "express-session": "^1.18.0", "method-override": "^3.0.0", @@ -85,6 +87,7 @@ "@vitest/coverage-v8": "^2.0.5", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", + "eslint-plugin-jsdoc": "^50.3.1", "eslint-plugin-prettier": "^5.2.1", "prettier": "^3.3.3", "rimraf": "^6.0.1", diff --git a/static/js/send.js b/static/js/send.js new file mode 100644 index 0000000..94367ec --- /dev/null +++ b/static/js/send.js @@ -0,0 +1,110 @@ +import { HexMix } from "/utils"; + +/** + * Initializes the send page functionality. + * This function sets up event listeners and handles the form submission process. + */ +export function initializeSendPage() { + const copyButton = document.getElementById("copyButton"); + const fileInput = document.getElementById("file"); + const form = document.getElementById("secretForm"); + const resultInput = document.getElementById("result"); + const encryptedDataTextarea = document.getElementById("encryptedData"); + + /** + * Copies the result URL to the clipboard. + */ + copyButton.addEventListener("click", async () => { + resultInput.select(); + await navigator.clipboard.writeText(resultInput.value); + alert("URL copied to clipboard!"); + }); + + /** + * Toggles the text input based on file selection. + * @param {Event} e - The change event. + */ + fileInput.addEventListener("change", (e) => { + const file = e.target.files[0]; + document.getElementById("text").disabled = !!file; + }); + + /** + * Handles the form submission. + * @param {Event} e - The submit event. + */ + form.addEventListener("submit", async (e) => { + e.preventDefault(); + + const formData = new FormData(form); + const text = formData.get("text"); + const file = formData.get("file"); + const serviceIdentifier = formData.get("serviceIdentifier"); + const service = formData.get("service"); + + let plaintext = text; + let filetype = "text/plain"; + let filename = "secret.txt"; + + if (file.size > 0) { + if (file.size > 2097152) { + alert("Error: Max file size is 2mb."); + return; + } + plaintext = await file.arrayBuffer(); + filetype = file.type; + filename = file.name; + } + + /** + * Generates a new AES-GCM key. + * @type {CryptoKey} + */ + const key = await window.crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]); + + const iv = window.crypto.getRandomValues(new Uint8Array(16)); + const exported = await window.crypto.subtle.exportKey("raw", key); + + /** + * Encrypts the plaintext. + * @type {ArrayBuffer} + */ + const encrypted = await window.crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + typeof plaintext === "string" ? HexMix.stringToArrayBuffer(plaintext) : plaintext + ); + + // Convert the encrypted ArrayBuffer to a Base64 string + const encryptedBase64 = HexMix.arrayBufferToBase64(encrypted); + encryptedDataTextarea.value = encryptedBase64; + + const keyHex = HexMix.uint8ToHex(new Uint8Array(exported)); + const ivHex = HexMix.uint8ToHex(iv); + const keyParams = { key: keyHex, iv: ivHex }; + + const params = JSON.stringify({ + service: service, + serviceIdentifier: serviceIdentifier, + text: encryptedBase64, + iv: ivHex, + filetype: filetype, + filename: filename + }); + + try { + const response = await fetch("/rest/links", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: params + }); + const data = await response.json(); + const url = `http://${window.location.host}/rest/links/${data.id}#${btoa(JSON.stringify(keyParams))}`; + resultInput.value = url; + } catch (err) { + alert(`Error: ${err.message}`); + } + }); +} diff --git a/static/js/utils.js b/static/js/utils.js new file mode 100644 index 0000000..1efb976 --- /dev/null +++ b/static/js/utils.js @@ -0,0 +1,46 @@ +/** + * Utility object for handling hexadecimal and binary data conversions. + * @namespace + */ +export const HexMix = { + /** + * Converts a Uint8Array to a hexadecimal string. + * @param {Uint8Array} data - The input Uint8Array. + * @returns {string} The hexadecimal representation of the input data. + */ + uint8ToHex(data) { + return Array.from(data, (byte) => ("00" + byte.toString(16)).slice(-2)).join(""); + }, + + /** + * Converts a hexadecimal string to a Uint8Array. + * @param {string} data - The input hexadecimal string. + * @returns {Uint8Array} The Uint8Array representation of the input data. + */ + hexToUint8(data) { + const hexArray = data.match(/.{1,2}/g); + return hexArray ? new Uint8Array(hexArray.map((char) => parseInt(char, 16))) : new Uint8Array(0); + }, + + /** + * Converts an ArrayBuffer to a Base64 string. + * @param {ArrayBuffer} buf - The input ArrayBuffer. + * @returns {string} The Base64 representation of the input data. + */ + arrayBufferToBase64(buf) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(buf))); + }, + + /** + * Converts a string to an ArrayBuffer. + * @param {string} str - The input string. + * @returns {ArrayBuffer} The ArrayBuffer representation of the input string. + */ + stringToArrayBuffer(str) { + const array = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) { + array[i] = str.charCodeAt(i); + } + return array.buffer; + } +}; diff --git a/tsconfig.json b/tsconfig.json index 29caea4..ebb1862 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,8 +26,8 @@ "typeRoots": ["./node_modules/@types"], "noEmitHelpers": false }, - "include": ["src"], + "include": ["src", "static/**/*.js"], "linterOptions": { - "exclude": [] + "exclude": ["static/es-module-shims.js"] } } diff --git a/views/app/send.ejs b/views/app/send.ejs index d13bc9c..7067776 100644 --- a/views/app/send.ejs +++ b/views/app/send.ejs @@ -2,177 +2,69 @@ <%- include('../layout/head.ejs', { title: 'Intended Link' }) %> + + - - <%- include('../layout/header.ejs') %> -
-
-
-

Securely Share Your Secrets

-

Only the person with the account you specify will be able to decrypt your message.

-
-
-

- - -

-

- - -

-

- - -

-

- - -

-

- -

-
+ + <%- include('../layout/header.ejs') %> +
+
+
+

Securely Share Your Secrets

+

Only the person with the account you specify will be able to decrypt your message.

+
+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+

+ +

+
-
-
- - - -
-
- - -
-
+
+
+ + +
-
+
+ + +
+
+ +
- - const copyButton = document.getElementById('copyButton'); - const fileInput = document.getElementById('file'); + <%- include('../layout/footer.ejs') %> + - copyButton.addEventListener('click', () => { - resultInput.select(); - document.execCommand('copy'); - alert('URL copied to clipboard!'); - }); - - fileInput.addEventListener('change', (e) => { - const file = e.target.files[0]; - if (file) { - document.getElementById('text').disabled = true; - } else { - document.getElementById('text').disabled = false; - } - }); - - const form = document.getElementById('secretForm'); - const resultInput = document.getElementById('result'); - const encryptedDataTextarea = document.getElementById('encryptedData'); - - - form.addEventListener('submit', async (e) => { - e.preventDefault(); - - const formData = new FormData(form); - const text = formData.get('text'); - const file = formData.get('file'); - const serviceIdentifier = formData.get('serviceIdentifier'); - const service = formData.get('service'); - - let plaintext = text; - let filetype = 'text/plain'; - let filename = 'secret.txt'; - - if (file.size > 0) { - if (file.size > 2097152) { - alert('Error: Max file size is 2mb.'); - return; - } - plaintext = await file.arrayBuffer(); - filetype = file.type; - filename = file.name; - } - - const key = await window.crypto.subtle.generateKey( - { name: 'AES-GCM', length: 256 }, - true, - ['encrypt', 'decrypt'] - ); - - const iv = window.crypto.getRandomValues(new Uint8Array(16)); - const exported = await window.crypto.subtle.exportKey('raw', key); - const encrypted = await window.crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, - key, - typeof plaintext === 'string' ? HexMix.stringToArrayBuffer(plaintext) : plaintext - ); - - HexMix.arrayBufferToString(encrypted, async (encryptedText) => { - encryptedDataTextarea.value = encryptedText; - - - const keyHex = HexMix.uint8ToHex(new Uint8Array(exported)); - const ivHex = HexMix.uint8ToHex(iv); - const keyParams = { key: keyHex, iv: ivHex }; - - const params = JSON.stringify({ - service: service, - serviceIdentifier: serviceIdentifier, - text: encryptedText, - iv: ivHex, - filetype: filetype, - filename: filename - }); - - try { - const response = await fetch('/rest/links', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: params - }); - const data = await response.json(); - const url = `http://${window.location.host}/rest/links/${data.id}#${btoa(JSON.stringify(keyParams))}`; - resultInput.value = url; - } catch (err) { - alert(`Error: ${err.message}`); - } - }) - - }); - - - - - <%- include('../layout/footer.ejs') %> - - - \ No newline at end of file + diff --git a/views/layout/head.ejs b/views/layout/head.ejs index 2e3ab64..1719fd6 100644 --- a/views/layout/head.ejs +++ b/views/layout/head.ejs @@ -22,4 +22,4 @@ - \ No newline at end of file +