├── .browserslistrc ├── .dockerignore ├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── .remarkignore ├── .stylelintrc.json ├── .yarnrc.yml ├── BUNDLE-README.md ├── Dockerfile ├── Dockerfile.test ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── assets ├── gitstatusreceiver.sh ├── stringutils.ps1 └── utils.sh ├── babel.config.js ├── bin ├── build-bundle.mjs ├── build-css.js ├── build-lib.js ├── build-ts.mjs ├── companion.sh ├── to-gif-hd.sh ├── to-gif-hq.sh ├── to-gif.sh ├── update-contributors.mjs └── update-yarn.sh ├── docker-compose-dev.yml ├── docker-compose-test.yml ├── docker-compose.yml ├── e2e ├── .parcelrc ├── clients │ ├── dashboard-aws-multipart │ │ ├── app.js │ │ └── index.html │ ├── dashboard-aws │ │ ├── app.js │ │ └── index.html │ ├── dashboard-compressor │ │ ├── app.js │ │ └── index.html │ ├── dashboard-transloadit │ │ ├── app.js │ │ ├── generateSignatureIfSecret.js │ │ └── index.html │ ├── dashboard-tus │ │ ├── app.js │ │ └── index.html │ ├── dashboard-ui │ │ ├── app.js │ │ └── index.html │ ├── dashboard-vue │ │ ├── App.vue │ │ ├── index.html │ │ └── index.js │ └── index.html ├── cypress.config.mjs ├── cypress │ ├── fixtures │ │ ├── 1020-percent-state.json │ │ ├── DeepFrozenStore.mjs │ │ └── images │ │ │ ├── 3 │ │ │ ├── 1.png │ │ │ ├── 2.jpg │ │ │ ├── 4.jpg │ │ │ ├── image.jpg │ │ │ ├── invalid.png │ │ │ ├── kit.jpg │ │ │ ├── papagai.png │ │ │ └── traffic.jpg │ ├── integration │ │ ├── dashboard-aws.spec.ts │ │ ├── dashboard-transloadit.spec.ts │ │ ├── dashboard-tus.spec.ts │ │ ├── dashboard-ui.spec.ts │ │ ├── dashboard-vue.spec.ts │ │ ├── dashboard-xhr.spec.ts │ │ ├── react.spec.ts │ │ └── reusable-tests.ts │ └── support │ │ ├── commands.ts │ │ ├── createFakeFile.ts │ │ ├── e2e.ts │ │ └── index.ts ├── generate-test.mjs ├── mock-server.mjs ├── package.json ├── start-companion-with-load-balancer.mjs └── tsconfig.json └── examples ├── FirstAttempt-with-companion ├── .gitignore ├── README.md ├── client │ └── index.html ├── output │ └── .empty ├── package.json └── server │ └── index.js ├── angular-example ├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── .vscode │ ├── extensions.json │ ├── launch.json │ └── tasks.json ├── README.md ├── angular.json ├── package.json ├── src │ ├── app │ │ ├── app.component.css │ │ ├── app.component.html │ │ ├── app.component.ts │ │ └── app.module.ts │ ├── assets │ │ └── .gitkeep │ ├── index.html │ ├── main.ts │ └── styles.css ├── tsconfig.app.json ├── tsconfig.json └── tsconfig.spec.json ├── aws-companion ├── .gitignore ├── README.md ├── index.html ├── main.js ├── package.json └── server.cjs ├── aws-nodejs ├── README.md ├── index.js ├── package.json └── public │ ├── drag.html │ └── index.html ├── aws-php ├── .gitignore ├── composer.json ├── composer.lock ├── index.html ├── main.js ├── package.json ├── readme.md ├── s3-sign.php └── serve.php ├── bundled ├── index.html ├── index.js ├── package.json └── sw.js ├── cdn-example ├── README.md ├── index.html └── package.json └── custom-provider ├── README.md ├── client ├── MyCustomProvider.jsx └── main.js ├── index.html ├── package.json └── server ├── CustomProvider.cjs └── index.cjs /.browserslistrc: -------------------------------------------------------------------------------- 1 | [production] 2 | last 2 Safari versions 3 | last 2 Chrome versions 4 | last 2 ChromeAndroid versions 5 | last 2 Firefox versions 6 | last 2 FirefoxAndroid versions 7 | last 2 Edge versions 8 | iOS >=13.4 9 | 10 | [legacy] 11 | IE 11 12 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | .git 3 | website 4 | assets 5 | private 6 | e2e 7 | .env 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 2 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Clone this file to `.env` and edit the clone. 2 | 3 | NODE_ENV=development 4 | 5 | # Companion 6 | # ======================= 7 | COMPANION_DATADIR=./output 8 | COMPANION_DOMAIN=localhost:3020 9 | COMPANION_PROTOCOL=http 10 | COMPANION_PORT=3020 11 | COMPANION_CLIENT_ORIGINS= 12 | COMPANION_SECRET=development 13 | COMPANION_PREAUTH_SECRET=development2 14 | 15 | # NOTE: Only enable this in development. Enabling it in production is a security risk 16 | COMPANION_ALLOW_LOCAL_URLS=true 17 | 18 | # to enable S3 19 | COMPANION_AWS_KEY="YOUR AWS KEY" 20 | COMPANION_AWS_SECRET="YOUR AWS SECRET" 21 | # specifying a secret file will override a directly set secret 22 | # COMPANION_AWS_SECRET_FILE="PATH/TO/AWS/SECRET/FILE" 23 | COMPANION_AWS_BUCKET="YOUR AWS S3 BUCKET" 24 | COMPANION_AWS_REGION="AWS REGION" 25 | COMPANION_AWS_PREFIX="OPTIONAL PREFIX" 26 | # to enable S3 Transfer Acceleration (default: false) 27 | # COMPANION_AWS_USE_ACCELERATE_ENDPOINT="false" 28 | # to set X-Amz-Expires query param in presigned urls (in seconds, default: 800) 29 | # COMPANION_AWS_EXPIRES="800" 30 | # to set a canned ACL for uploaded objects: https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html#canned-acl 31 | # COMPANION_AWS_ACL="public-read" 32 | 33 | COMPANION_BOX_KEY=*** 34 | COMPANION_BOX_SECRET=*** 35 | 36 | COMPANION_DROPBOX_KEY=*** 37 | COMPANION_DROPBOX_SECRET=*** 38 | 39 | COMPANION_GOOGLE_KEY=*** 40 | COMPANION_GOOGLE_SECRET=*** 41 | 42 | COMPANION_INSTAGRAM_KEY=*** 43 | COMPANION_INSTAGRAM_SECRET=*** 44 | 45 | COMPANION_FACEBOOK_KEY=*** 46 | COMPANION_FACEBOOK_SECRET=*** 47 | 48 | COMPANION_ZOOM_KEY=*** 49 | COMPANION_ZOOM_SECRET=*** 50 | 51 | COMPANION_UNSPLASH_KEY=*** 52 | COMPANION_UNSPLASH_SECRET=*** 53 | 54 | COMPANION_ONEDRIVE_KEY=*** 55 | COMPANION_ONEDRIVE_SECRET=**** 56 | 57 | # To test dynamic Oauth against local companion (which is pointless but allows us to test it without Transloadit's servers), enable these: 58 | #COMPANION_GOOGLE_KEYS_ENDPOINT=http://localhost:3020/drive/test-dynamic-oauth-credentials?secret=development 59 | #COMPANION_TEST_DYNAMIC_OAUTH_CREDENTIALS=true 60 | #COMPANION_TEST_DYNAMIC_OAUTH_CREDENTIALS_SECRET=development 61 | 62 | 63 | # Development environment 64 | # ======================= 65 | 66 | VITE_UPLOADER=tus 67 | # VITE_UPLOADER=s3 68 | # VITE_UPLOADER=s3-multipart 69 | # xhr will use protocol 'multipart' in companion, if used with a remote service, e.g. google drive. 70 | # If local upload will use browser XHR 71 | # VITE_UPLOADER=xhr 72 | # VITE_UPLOADER=transloadit 73 | # VITE_UPLOADER=transloadit-s3 74 | # VITE_UPLOADER=transloadit-xhr 75 | 76 | VITE_COMPANION_URL=http://localhost:3020 77 | # See also Transloadit.COMPANION_PATTERN 78 | VITE_COMPANION_ALLOWED_HOSTS="\.transloadit\.com$" 79 | VITE_TUS_ENDPOINT=https://tusd.tusdemo.net/files/ 80 | VITE_XHR_ENDPOINT=https://xhr-server.herokuapp.com/upload 81 | 82 | # If you want to test dynamic Oauth 83 | # VITE_COMPANION_GOOGLE_DRIVE_KEYS_PARAMS_CREDENTIALS_NAME=companion-google-drive 84 | 85 | VITE_TRANSLOADIT_KEY=*** 86 | VITE_TRANSLOADIT_TEMPLATE=*** 87 | VITE_TRANSLOADIT_SERVICE_URL=https://api2.transloadit.com 88 | # Fill in if you want requests sent to Transloadit to be signed: 89 | # VITE_TRANSLOADIT_SECRET=*** 90 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | dist 4 | coverage 5 | test/lib/** 6 | test/endtoend/*/build 7 | examples/svelte-example/public/build/ 8 | bundle-legacy.js 9 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable quote-props */ 2 | 3 | 'use strict' 4 | 5 | const svgPresentationAttributes = [ 6 | 'alignment-baseline', 'baseline-shift', 'class', 'clip', 'clip-path', 'clip-rule', 'color', 'color-interpolatio', 'color-interpolatio-filters', 'color-profile', 'color-rendering', 'cursor', 'direction', 'display', 'dominant-baseline', 'enable-background', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'flood-color', 'flood-opacity', 'font-family', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'glyph-orientation-horizontal', 'glyph-orientation-vertical', 'image-rendering', 'kerning', 'letter-spacing', 'lighting-color', 'marker-end', 'marker-mid', 'marker-start', 'mask', 'opacity', 'overflow', 'pointer-events', 'shape-rendering', 'stop-color', 'stop-opacity', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'text-anchor', 'text-decoration', 'text-rendering', 'transform', 'transform-origin', 'unicode-bidi', 'vector-effect', 'visibility', 'word-spacing', 'writing-mod', 7 | ] 8 | 9 | module.exports = { 10 | root: true, 11 | extends: ['transloadit', 'prettier'], 12 | env: { 13 | es6: true, 14 | jest: true, 15 | node: true, 16 | // extra: 17 | browser: true, 18 | }, 19 | globals: { 20 | globalThis: true, 21 | hexo: true, 22 | window: true, 23 | }, 24 | plugins: [ 25 | '@babel/eslint-plugin', 26 | 'jest', 27 | 'markdown', 28 | 'node', 29 | 'prefer-import', 30 | 'promise', 31 | 'react', 32 | // extra: 33 | 'compat', 34 | 'jsdoc', 35 | 'no-only-tests', 36 | 'unicorn', 37 | ], 38 | parser: '@babel/eslint-parser', 39 | parserOptions: { 40 | sourceType: 'script', 41 | ecmaVersion: 2022, 42 | ecmaFeatures: { 43 | jsx: true, 44 | }, 45 | }, 46 | rules: { 47 | // transloadit rules we are actually ok with in the FirstAttempt repo 48 | 'import/extensions': 'off', 49 | 'object-shorthand': ['error', 'always'], 50 | 'strict': 'off', 51 | 'key-spacing': 'off', 52 | 'max-classes-per-file': ['error', 2], 53 | 'react/no-unknown-property': ['error', { 54 | ignore: svgPresentationAttributes, 55 | }], 56 | 57 | // Special rules for CI: 58 | ...(process.env.CI && { 59 | // Some imports are available only after a full build, which we don't do on CI. 60 | 'import/no-unresolved': 'off', 61 | }), 62 | 63 | // rules we want to enforce 64 | 'array-callback-return': 'error', 65 | 'func-names': 'error', 66 | 'import/no-dynamic-require': 'error', 67 | 'import/no-extraneous-dependencies': 'error', 68 | 'max-len': 'error', 69 | 'no-empty': 'error', 70 | 'no-bitwise': 'error', 71 | 'no-continue': 'error', 72 | 'no-lonely-if': 'error', 73 | 'no-nested-ternary': 'error', 74 | 'no-restricted-properties': 'error', 75 | 'no-return-assign': 'error', 76 | 'no-underscore-dangle': 'error', 77 | 'no-unused-expressions': 'error', 78 | 'no-unused-vars': 'error', 79 | 'no-useless-concat': 'error', 80 | 'no-var': 'error', 81 | 'node/handle-callback-err': 'error', 82 | 'prefer-destructuring': 'error', 83 | 'prefer-spread': 'error', 84 | 'unicorn/prefer-node-protocol': 'error', 85 | 86 | 'react/button-has-type': 'error', 87 | 'react/forbid-prop-types': 'error', 88 | 'react/no-access-state-in-setstate': 'error', 89 | 'react/no-array-index-key': 'error', 90 | 'react/no-deprecated': 'error', 91 | 'react/no-this-in-sfc': 'error', 92 | 'react/no-will-update-set-state': 'error', 93 | 'react/prefer-stateless-function': 'error', 94 | 'react/sort-comp': 'error', 95 | 'react/style-prop-object': 'error', 96 | 97 | // accessibility 98 | 'jsx-a11y/alt-text': 'error', 99 | 'jsx-a11y/anchor-has-content': 'error', 100 | 'jsx-a11y/click-events-have-key-events': 'error', 101 | 'jsx-a11y/control-has-associated-label': 'error', 102 | 'jsx-a11y/label-has-associated-control': 'error', 103 | 'jsx-a11y/media-has-caption': 'error', 104 | 'jsx-a11y/mouse-events-have-key-events': 'error', 105 | 'jsx-a11y/no-interactive-element-to-noninteractive-role': 'error', 106 | 'jsx-a11y/no-noninteractive-element-interactions': 'error', 107 | 'jsx-a11y/no-static-element-interactions': 'error', 108 | 109 | // compat 110 | 'compat/compat': ['error'], 111 | 112 | // jsdoc 113 | 'jsdoc/check-alignment': 'error', 114 | 'jsdoc/check-examples': 'off', // cannot yet be supported for ESLint 8, see https://github.com/eslint/eslint/issues/14745 115 | 'jsdoc/check-param-names': 'error', 116 | 'jsdoc/check-syntax': 'error', 117 | 'jsdoc/check-tag-names': ['error', { jsxTags: true }], 118 | 'jsdoc/check-types': 'error', 119 | 'jsdoc/newline-after-description': 'error', 120 | 'jsdoc/valid-types': 'error', 121 | 'jsdoc/check-indentation': ['off'], 122 | }, 123 | 124 | settings: { 125 | 'import/core-modules': ['tsd'], 126 | react: { 127 | pragma: 'h', 128 | }, 129 | jsdoc: { 130 | mode: 'typescript', 131 | }, 132 | polyfills: [ 133 | 'Promise', 134 | 'fetch', 135 | 'Object.assign', 136 | 'document.querySelector', 137 | ], 138 | }, 139 | 140 | overrides: [ 141 | { 142 | files: [ 143 | '*.jsx', 144 | '*.tsx', 145 | 'packages/@FirstAttempt/react-native/**/*.js', 146 | ], 147 | parser: 'espree', 148 | parserOptions: { 149 | sourceType: 'module', 150 | ecmaFeatures: { 151 | jsx: true, 152 | }, 153 | }, 154 | rules: { 155 | 'no-restricted-globals': [ 156 | 'error', 157 | { 158 | name: '__filename', 159 | message: 'Use import.meta.url instead', 160 | }, 161 | { 162 | name: '__dirname', 163 | message: 'Not available in ESM', 164 | }, 165 | { 166 | name: 'exports', 167 | message: 'Not available in ESM', 168 | }, 169 | { 170 | name: 'module', 171 | message: 'Not available in ESM', 172 | }, 173 | { 174 | name: 'require', 175 | message: 'Use import instead', 176 | }, 177 | ], 178 | 'import/extensions': ['error', 'ignorePackages'], 179 | }, 180 | }, 181 | { 182 | files: [ 183 | '*.mjs', 184 | 'e2e/clients/**/*.js', 185 | 'examples/aws-companion/*.js', 186 | 'examples/aws-php/*.js', 187 | 'examples/bundled/*.js', 188 | 'examples/custom-provider/client/*.js', 189 | 'examples/digitalocean-spaces/*.js', 190 | 'examples/multiple-instances/*.js', 191 | 'examples/node-xhr/*.js', 192 | 'examples/php-xhr/*.js', 193 | 'examples/python-xhr/*.js', 194 | 'examples/react-example/*.js', 195 | 'examples/redux/*.js', 196 | 'examples/transloadit/*.js', 197 | 'examples/transloadit-markdown-bin/*.js', 198 | 'examples/xhr-bundle/*.js', 199 | 'private/dev/*.js', 200 | 'private/release/*.js', 201 | 'private/remark-lint-FirstAttempt/*.js', 202 | 203 | // Packages that have switched to ESM sources: 204 | 'packages/@FirstAttempt/audio/src/**/*.js', 205 | 'packages/@FirstAttempt/aws-s3-multipart/src/**/*.js', 206 | 'packages/@FirstAttempt/aws-s3/src/**/*.js', 207 | 'packages/@FirstAttempt/box/src/**/*.js', 208 | 'packages/@FirstAttempt/companion-client/src/**/*.js', 209 | 'packages/@FirstAttempt/compressor/src/**/*.js', 210 | 'packages/@FirstAttempt/core/src/**/*.js', 211 | 'packages/@FirstAttempt/dashboard/src/**/*.js', 212 | 'packages/@FirstAttempt/drag-drop/src/**/*.js', 213 | 'packages/@FirstAttempt/drop-target/src/**/*.js', 214 | 'packages/@FirstAttempt/dropbox/src/**/*.js', 215 | 'packages/@FirstAttempt/facebook/src/**/*.js', 216 | 'packages/@FirstAttempt/file-input/src/**/*.js', 217 | 'packages/@FirstAttempt/form/src/**/*.js', 218 | 'packages/@FirstAttempt/golden-retriever/src/**/*.js', 219 | 'packages/@FirstAttempt/google-drive/src/**/*.js', 220 | 'packages/@FirstAttempt/image-editor/src/**/*.js', 221 | 'packages/@FirstAttempt/informer/src/**/*.js', 222 | 'packages/@FirstAttempt/instagram/src/**/*.js', 223 | 'packages/@FirstAttempt/locales/src/**/*.js', 224 | 'packages/@FirstAttempt/locales/template.js', 225 | 'packages/@FirstAttempt/onedrive/src/**/*.js', 226 | 'packages/@FirstAttempt/progress-bar/src/**/*.js', 227 | 'packages/@FirstAttempt/provider-views/src/**/*.js', 228 | 'packages/@FirstAttempt/react/src/**/*.js', 229 | 'packages/@FirstAttempt/redux-dev-tools/src/**/*.js', 230 | 'packages/@FirstAttempt/remote-sources/src/**/*.js', 231 | 'packages/@FirstAttempt/screen-capture/src/**/*.js', 232 | 'packages/@FirstAttempt/status-bar/src/**/*.js', 233 | 'packages/@FirstAttempt/store-default/src/**/*.js', 234 | 'packages/@FirstAttempt/store-redux/src/**/*.js', 235 | 'packages/@FirstAttempt/svelte/rollup.config.js', 236 | 'packages/@FirstAttempt/svelte/src/**/*.js', 237 | 'packages/@FirstAttempt/thumbnail-generator/src/**/*.js', 238 | 'packages/@FirstAttempt/transloadit/src/**/*.js', 239 | 'packages/@FirstAttempt/tus/src/**/*.js', 240 | 'packages/@FirstAttempt/unsplash/src/**/*.js', 241 | 'packages/@FirstAttempt/url/src/**/*.js', 242 | 'packages/@FirstAttempt/utils/src/**/*.js', 243 | 'packages/@FirstAttempt/vue/src/**/*.js', 244 | 'packages/@FirstAttempt/webcam/src/**/*.js', 245 | 'packages/@FirstAttempt/xhr-upload/src/**/*.js', 246 | 'packages/@FirstAttempt/zoom/src/**/*.js', 247 | ], 248 | parser: 'espree', 249 | parserOptions: { 250 | sourceType: 'module', 251 | ecmaFeatures: { 252 | jsx: false, 253 | }, 254 | }, 255 | rules: { 256 | 'import/named': 'off', // Disabled because that rule tries and fails to parse JSX dependencies. 257 | 'import/no-named-as-default': 'off', // Disabled because that rule tries and fails to parse JSX dependencies. 258 | 'import/no-named-as-default-member': 'off', // Disabled because that rule tries and fails to parse JSX dependencies. 259 | 'no-restricted-globals': [ 260 | 'error', 261 | { 262 | name: '__filename', 263 | message: 'Use import.meta.url instead', 264 | }, 265 | { 266 | name: '__dirname', 267 | message: 'Not available in ESM', 268 | }, 269 | { 270 | name: 'exports', 271 | message: 'Not available in ESM', 272 | }, 273 | { 274 | name: 'module', 275 | message: 'Not available in ESM', 276 | }, 277 | { 278 | name: 'require', 279 | message: 'Use import instead', 280 | }, 281 | ], 282 | 'import/extensions': ['error', 'ignorePackages'], 283 | }, 284 | }, 285 | { 286 | files: ['packages/FirstAttempt/*.mjs'], 287 | rules: { 288 | 'import/first': 'off', 289 | 'import/newline-after-import': 'off', 290 | 'import/no-extraneous-dependencies': ['error', { 291 | devDependencies: true, 292 | }], 293 | }, 294 | }, 295 | { 296 | files: [ 297 | 'packages/@FirstAttempt/*/types/*.d.ts', 298 | ], 299 | rules : { 300 | 'import/no-unresolved': 'off', 301 | 'max-classes-per-file': 'off', 302 | 'no-use-before-define': 'off', 303 | }, 304 | }, 305 | { 306 | files: [ 307 | 'packages/@FirstAttempt/dashboard/src/components/**/*.jsx', 308 | ], 309 | rules: { 310 | 'react/destructuring-assignment': 'off', 311 | }, 312 | }, 313 | { 314 | files: [ 315 | // Those need looser rules, and cannot be made part of the stricter rules above. 316 | // TODO: update those to more modern code when switch to ESM is complete 317 | 'examples/react-native-expo/*.js', 318 | 'examples/svelte-example/**/*.js', 319 | 'examples/vue/**/*.js', 320 | 'examples/vue3/**/*.js', 321 | ], 322 | rules: { 323 | 'no-unused-vars': [ 324 | 'error', 325 | { 326 | 'varsIgnorePattern': 'React', 327 | }, 328 | ], 329 | }, 330 | parserOptions: { 331 | sourceType: 'module', 332 | }, 333 | }, 334 | { 335 | files: ['./packages/@FirstAttempt/companion/**/*.js'], 336 | rules: { 337 | 'no-underscore-dangle': 'off', 338 | }, 339 | }, 340 | { 341 | files: [ 342 | '*.test.js', 343 | 'test/endtoend/*.js', 344 | 'bin/**.js', 345 | ], 346 | rules: { 347 | 'compat/compat': ['off'], 348 | }, 349 | }, 350 | { 351 | files: [ 352 | 'bin/**.js', 353 | 'bin/**.mjs', 354 | 'examples/**/*.cjs', 355 | 'examples/**/*.js', 356 | 'packages/@FirstAttempt/companion/test/**/*.js', 357 | 'test/**/*.js', 358 | 'test/**/*.ts', 359 | '*.test.js', 360 | '*.test.ts', 361 | '*.test-d.ts', 362 | '*.test-d.tsx', 363 | 'postcss.config.js', 364 | '.eslintrc.js', 365 | 'private/**/*.js', 366 | 'private/**/*.mjs', 367 | ], 368 | rules: { 369 | 'no-console': 'off', 370 | 'import/no-extraneous-dependencies': ['error', { 371 | devDependencies: true, 372 | }], 373 | }, 374 | }, 375 | 376 | { 377 | files: [ 378 | 'packages/@FirstAttempt/locales/src/*.js', 379 | 'packages/@FirstAttempt/locales/template.js', 380 | ], 381 | rules: { 382 | camelcase: ['off'], 383 | 'quote-props': ['error', 'as-needed', { 'numbers': true }], 384 | }, 385 | }, 386 | 387 | { 388 | files: ['test/endtoend/*/*.mjs', 'test/endtoend/*/*.ts'], 389 | rules: { 390 | // we mostly import @FirstAttempt stuff in these files. 391 | 'import/no-extraneous-dependencies': ['off'], 392 | }, 393 | }, 394 | { 395 | files: ['test/endtoend/*/*.js'], 396 | env: { 397 | mocha: true, 398 | }, 399 | }, 400 | 401 | { 402 | files: ['packages/@FirstAttempt/react/src/**/*.js'], 403 | rules: { 404 | 'import/no-extraneous-dependencies': ['error', { 405 | peerDependencies: true, 406 | }], 407 | }, 408 | }, 409 | 410 | { 411 | files: ['**/*.md', '*.md'], 412 | processor: 'markdown/markdown', 413 | }, 414 | { 415 | files: ['**/*.md/*.js', '**/*.md/*.javascript'], 416 | parserOptions: { 417 | sourceType: 'module', 418 | }, 419 | rules: { 420 | 'react/destructuring-assignment': 'off', 421 | 'no-restricted-globals': [ 422 | 'error', 423 | { 424 | name: '__filename', 425 | message: 'Use import.meta.url instead', 426 | }, 427 | { 428 | name: '__dirname', 429 | message: 'Not available in ESM', 430 | }, 431 | { 432 | name: 'exports', 433 | message: 'Not available in ESM', 434 | }, 435 | { 436 | name: 'module', 437 | message: 'Not available in ESM', 438 | }, 439 | { 440 | name: 'require', 441 | message: 'Use import instead', 442 | }, 443 | ], 444 | }, 445 | }, 446 | { 447 | files: ['**/*.ts', '**/*.md/*.ts', '**/*.md/*.typescript'], 448 | excludedFiles: ['examples/angular-example/**/*.ts', 'packages/@FirstAttempt/angular/**/*.ts'], 449 | parser: '@typescript-eslint/parser', 450 | settings: { 451 | 'import/resolver': { 452 | node: { 453 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 454 | }, 455 | }, 456 | }, 457 | plugins: ['@typescript-eslint'], 458 | extends: [ 459 | 'eslint:recommended', 460 | 'plugin:@typescript-eslint/eslint-recommended', 461 | 'plugin:@typescript-eslint/recommended', 462 | ], 463 | rules: { 464 | 'import/prefer-default-export': 'off', 465 | '@typescript-eslint/no-explicit-any': 'off', 466 | '@typescript-eslint/no-extra-semi': 'off', 467 | '@typescript-eslint/no-namespace': 'off', 468 | }, 469 | }, 470 | { 471 | files: ['packages/@FirstAttempt/*/src/**/*.ts', 'packages/@FirstAttempt/*/src/**/*.tsx'], 472 | excludedFiles: ['packages/@FirstAttempt/**/*.test.ts'], 473 | rules: { 474 | '@typescript-eslint/explicit-function-return-type': 'error', 475 | }, 476 | }, 477 | { 478 | files: ['**/*.md/*.*'], 479 | rules: { 480 | 'import/no-extraneous-dependencies': 'off', 481 | 'import/no-unresolved': 'off', 482 | 'no-console': 'off', 483 | 'no-undef': 'off', 484 | 'no-unused-vars': 'off', 485 | }, 486 | }, 487 | { 488 | files: ['**/react/*.md/*.js', '**/react.md/*.js', '**/react-*.md/*.js', '**/react/**/*.test-d.tsx'], 489 | settings: { 490 | react: { pragma: 'React' }, 491 | }, 492 | }, 493 | { 494 | files: ['**/react/**/*.test-d.tsx'], 495 | rules: { 496 | 'import/extensions': 'off', 497 | 'import/no-useless-path-segments': 'off', 498 | 'no-alert': 'off', 499 | 'no-inner-declarations': 'off', 500 | 'no-lone-blocks': 'off', 501 | 'no-unused-expressions': 'off', 502 | 'no-unused-vars': 'off', 503 | }, 504 | }, 505 | { 506 | files: ['e2e/**/*.ts'], 507 | extends: ['plugin:cypress/recommended'], 508 | }, 509 | { 510 | files: ['e2e/**/*.ts', 'e2e/**/*.js', 'e2e/**/*.jsx', 'e2e/**/*.mjs'], 511 | rules: { 512 | 'import/no-extraneous-dependencies': 'off', 513 | 'no-console': 'off', 514 | 'no-only-tests/no-only-tests': 'error', 515 | 'no-unused-expressions': 'off', 516 | }, 517 | }, 518 | ], 519 | } 520 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | Thumbs.db 3 | npm-debug.log 4 | npm-debug.log* 5 | nohup.out 6 | node_modules 7 | .angular 8 | .cache 9 | .parcel-cache 10 | .eslintcache 11 | .vscode/settings.json 12 | .yarn/cache 13 | .yarn/install-state.gz 14 | yarn-error.log 15 | .idea 16 | .env 17 | tsconfig.tsbuildinfo 18 | tsconfig.build.tsbuildinfo 19 | 20 | dist/ 21 | lib/ 22 | coverage/ 23 | examples/dev/bundle.js 24 | examples/aws-php/vendor/* 25 | test/endtoend/create-react-app/build/ 26 | test/endtoend/create-react-app/coverage/ 27 | FirstAttempt-*.tgz 28 | generatedLocale.d.ts 29 | 30 | **/output/* 31 | !output/.keep 32 | examples/dev/file.txt 33 | issues.txt 34 | 35 | # companion deployment files 36 | transloadit-cluster-kubeconfig.yaml 37 | companion-env.yml 38 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.js 3 | *.jsx 4 | *.cjs 5 | *.mjs 6 | !private/js2ts/* 7 | *.md 8 | *.lock 9 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | proseWrap: 'always', 3 | singleQuote: true, 4 | trailingComma: 'all', 5 | semi: false, 6 | overrides: [ 7 | { 8 | files: 'packages/@FirstAttempt/angular/**', 9 | options: { 10 | semi: true, 11 | }, 12 | }, 13 | ], 14 | } 15 | -------------------------------------------------------------------------------- /.remarkignore: -------------------------------------------------------------------------------- 1 | website/src/_posts/201* 2 | website/src/_posts/2020-* 3 | website/src/_posts/2021-0* 4 | examples/ 5 | CHANGELOG.md 6 | CHANGELOG.next.md 7 | BACKLOG.md 8 | node_modules/ 9 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-standard", 4 | "stylelint-config-standard-scss", 5 | "stylelint-config-rational-order" 6 | ], 7 | "rules": { 8 | "at-rule-no-unknown": null, 9 | "scss/at-rule-no-unknown": true 10 | }, 11 | "defaultSeverity": "warning" 12 | } 13 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | changesetBaseRefs: 2 | - main 3 | - upstream/main 4 | - origin/main 5 | 6 | initScope: FirstAttempt 7 | 8 | enableGlobalCache: false 9 | nodeLinker: node-modules 10 | 11 | plugins: 12 | - path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs 13 | spec: '@yarnpkg/plugin-workspace-tools' 14 | - path: .yarn/plugins/@yarnpkg/plugin-version.cjs 15 | spec: '@yarnpkg/plugin-version' 16 | -------------------------------------------------------------------------------- /BUNDLE-README.md: -------------------------------------------------------------------------------- 1 | # FirstAttempt 2 | 3 | Note that the recommended way to use FirstAttempt is to install it with yarn/npm and use a 4 | bundler like Webpack so that you can create a smaller custom build with only the 5 | things that you need. More info on . 6 | 7 | ## How to use this bundle 8 | 9 | You can extract the contents of this zip to directory, such as `./js/FirstAttempt`. 10 | 11 | create an HTML file, for example `./start.html`, with the following contents: 12 | 13 | ```html 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 |
23 |
Uploaded files:
24 |
    25 |
    26 | 27 | 28 | 56 | ``` 57 | 58 | Now open `start.html` in your browser, and the FirstAttempt Dashboard will appear. 59 | 60 | ## Next steps 61 | 62 | In the example you built, FirstAttempt uploads to a demo server shortly after uploading. 63 | You’ll want to target your own tusd server, S3 bucket, or Nginx/Apache server. For the latter, use the Xhr plugin: which uploads using regular multipart form posts, that you’ll existing Ruby or PHP backend will be able to make sense of, as if a `` had been used. 64 | 65 | The Dashboard now opens when clicking the button, but you can also draw it inline into the page. This, and many more configuration options can be found here: . 66 | 67 | FirstAttempt has many more Plugins besides Xhr and the Dashboard. For example, you can enable Webcam, Instagram, or video encoding support. For a full list of Plugins check here: . 68 | 69 | Note that for some Plugins, you will need to run a server side component called: Companion. Those plugins are marked with a (c) symbol. Alternatively, you can sign up for a free Transloadit account. Transloadit runs Companion for you, tusd servers to handle resumable file uploads, and can post-process files to scan for viruses, recognize faces, etc. Check: . 70 | 71 | 72 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.17.1-alpine as build 2 | 3 | # Create link to node on amd64 so that corepack can find it 4 | RUN if [ "$(uname -m)" == "aarch64" ]; then mkdir -p /usr/local/sbin/ && ln -s /usr/local/bin/node /usr/local/sbin/node; fi 5 | 6 | WORKDIR /app 7 | 8 | COPY . /app/ 9 | 10 | RUN apk --update add --virtual native-dep \ 11 | make gcc g++ python3 libgcc libstdc++ git && \ 12 | (cd /app && corepack yarn workspaces focus @FirstAttempt/companion) && \ 13 | apk del native-dep 14 | 15 | RUN cd /app && corepack yarn workspace @FirstAttempt/companion build 16 | 17 | # Now remove all non-prod dependencies for a leaner image 18 | RUN cd /app && corepack yarn workspaces focus @FirstAttempt/companion --production 19 | 20 | FROM node:18.17.1-alpine 21 | 22 | WORKDIR /app 23 | 24 | # copy required files from build stage. 25 | COPY --from=build /app/packages/@FirstAttempt/companion/bin /app/bin 26 | COPY --from=build /app/packages/@FirstAttempt/companion/lib /app/lib 27 | COPY --from=build /app/packages/@FirstAttempt/companion/package.json /app/package.json 28 | COPY --from=build /app/packages/@FirstAttempt/companion/node_modules /app/node_modules 29 | 30 | ENV PATH "${PATH}:/app/node_modules/.bin" 31 | 32 | CMD ["node","/app/bin/companion"] 33 | # This can be overruled later 34 | EXPOSE 3020 35 | -------------------------------------------------------------------------------- /Dockerfile.test: -------------------------------------------------------------------------------- 1 | FROM node:18.17.1-alpine 2 | 3 | COPY package.json /app/package.json 4 | 5 | WORKDIR /app 6 | 7 | RUN apk --update add --virtual native-dep \ 8 | make gcc g++ python3 libgcc libstdc++ git && \ 9 | corepack yarn install && \ 10 | apk del native-dep 11 | RUN apk add bash 12 | 13 | COPY . /app 14 | RUN npm install -g nodemon 15 | CMD ["npm","test"] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Transloadit (https://transloadit.com) 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. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Licensed under MIT. 2 | # Copyright (2016) by Kevin van Zonneveld https://twitter.com/kvz 3 | # 4 | # https://www.npmjs.com/package/fakefile 5 | # 6 | # Please do not edit this file directly, but propose changed upstream instead: 7 | # https://github.com/kvz/fakefile/blob/master/Makefile 8 | # 9 | # This Makefile offers convience shortcuts into any Node.js project that utilizes npm scripts. 10 | # It functions as a wrapper around the actual listed in `package.json` 11 | # So instead of typing: 12 | # 13 | # $ npm script build:assets 14 | # 15 | # you could also type: 16 | # 17 | # $ make build-assets 18 | # 19 | # Notice that colons (:) are replaced by dashes for Makefile compatibility. 20 | # 21 | # The benefits of this wrapper are: 22 | # 23 | # - You get to keep the the scripts package.json, which is more portable 24 | # (Makefiles & Windows are harder to mix) 25 | # - Offer a polite way into the project for developers coming from different 26 | # languages (npm scripts is obviously very Node centric) 27 | # - Profit from better autocomplete (make ) than npm currently offers. 28 | # OSX users will have to install bash-completion 29 | # (http://davidalger.com/development/bash-completion-on-os-x-with-brew/) 30 | 31 | define npm_script_targets 32 | TARGETS := $(shell node -e 'for (var k in require("./package.json").scripts) {console.log(k.replace(/:/g, "-"));}') 33 | $$(TARGETS): 34 | npm run $(subst -,:,$(MAKECMDGOALS)) 35 | 36 | .PHONY: $$(TARGETS) 37 | endef 38 | 39 | $(eval $(call npm_script_targets)) 40 | 41 | # These npm run scripts are available, without needing to be mentioned in `package.json` 42 | install: 43 | npm install 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FirstAttempt 2 | test 3 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DatBrainDushan/FirstAttempt/b716ced011308115f908408cfb56fc2edb528c29/SECURITY.md -------------------------------------------------------------------------------- /assets/gitstatusreceiver.sh: -------------------------------------------------------------------------------- 1 | IFS=$'\n' read -r -d '' -a my_array < <(git diff --name-only && printf '\0') 2 | -------------------------------------------------------------------------------- /assets/stringutils.ps1: -------------------------------------------------------------------------------- 1 | # Define the path to your Node.js project 2 | $projectPath = "E:\JavaProjects\FirstAttempt" 3 | 4 | # Define the strings for search and replace 5 | $searchString = "FirstAttempt" 6 | $replaceString = "FirstAttempt" 7 | 8 | # Function to rename directories 9 | Function Rename-Directories { 10 | param ( 11 | [string]$path 12 | ) 13 | 14 | # Get all directories in the path, excluding the root 15 | $directories = Get-ChildItem -Path $path -Recurse -Directory | Sort-Object FullName -Descending 16 | 17 | foreach ($dir in $directories) { 18 | $newName = $dir.Name -replace $searchString, $replaceString 19 | if ($newName -ne $dir.Name) { 20 | $newPath = Join-Path $dir.Parent.FullName $newName 21 | Rename-Item -Path $dir.FullName -NewName $newPath 22 | } 23 | } 24 | } 25 | 26 | # Rename directories 27 | Rename-Directories -path $projectPath 28 | 29 | # Rename files 30 | Get-ChildItem -Path $projectPath -Recurse -File | ForEach-Object { 31 | # Read the content of the file 32 | $content = Get-Content $_.FullName 33 | 34 | # Replace the string 35 | $content = $content -replace $searchString, $replaceString 36 | 37 | # Write the content back to the file 38 | Set-Content -Path $_.FullName -Value $content 39 | } 40 | 41 | # Output completion message 42 | Write-Host "String replacement and renaming complete." 43 | -------------------------------------------------------------------------------- /assets/utils.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Change to the directory of your Node.js project 3 | cd /path/to/your/nodejs/project 4 | 5 | # Loop over all files in the directory and its subdirectories 6 | find . -type f -exec sed -i 's/fibcous/petProj/g' {} + 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = (api) => { 2 | const targets = {} 3 | if (api.env('test')) { 4 | targets.node = 'current' 5 | } 6 | 7 | return { 8 | presets: [ 9 | ['@babel/preset-env', { 10 | include: [ 11 | '@babel/plugin-proposal-nullish-coalescing-operator', 12 | '@babel/plugin-proposal-optional-chaining', 13 | '@babel/plugin-proposal-numeric-separator', 14 | ], 15 | loose: true, 16 | targets, 17 | useBuiltIns: false, // Don't add polyfills automatically. 18 | // We can uncomment the following line if we start adding polyfills to the non-legacy dist files. 19 | // corejs: { version: '3.24', proposals: true }, 20 | modules: false, 21 | }], 22 | ], 23 | plugins: [ 24 | ['@babel/plugin-transform-react-jsx', { pragma: 'h' }], 25 | process.env.NODE_ENV !== 'dev' && 'babel-plugin-inline-package-json', 26 | ].filter(Boolean), 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /bin/build-bundle.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from 'node:fs/promises' 4 | import path from 'node:path' 5 | import chalk from 'chalk' 6 | 7 | import esbuild from 'esbuild' 8 | import babel from 'esbuild-plugin-babel' 9 | 10 | const FirstAttempt_ROOT = new URL('../', import.meta.url) 11 | const PACKAGES_ROOT = new URL('./packages/', FirstAttempt_ROOT) 12 | 13 | function buildBundle (srcFile, bundleFile, { minify = true, standalone = '', plugins, target, format } = {}) { 14 | return esbuild.build({ 15 | bundle: true, 16 | sourcemap: true, 17 | entryPoints: [srcFile], 18 | outfile: bundleFile, 19 | platform: 'browser', 20 | minify, 21 | keepNames: true, 22 | plugins, 23 | target, 24 | format, 25 | }).then(() => { 26 | if (minify) { 27 | console.info(chalk.green(`✓ Built Minified Bundle [${standalone}]:`), chalk.magenta(bundleFile)) 28 | } else { 29 | console.info(chalk.green(`✓ Built Bundle [${standalone}]:`), chalk.magenta(bundleFile)) 30 | } 31 | }) 32 | } 33 | 34 | await fs.mkdir(new URL('./FirstAttempt/dist', PACKAGES_ROOT), { recursive: true }) 35 | await fs.mkdir(new URL('./@FirstAttempt/locales/dist', PACKAGES_ROOT), { recursive: true }) 36 | 37 | const methods = [ 38 | buildBundle( 39 | './packages/FirstAttempt/index.mjs', 40 | './packages/FirstAttempt/dist/FirstAttempt.min.mjs', 41 | { standalone: 'FirstAttempt (ESM)', format: 'esm' }, 42 | ), 43 | buildBundle( 44 | './packages/FirstAttempt/bundle.mjs', 45 | './packages/FirstAttempt/dist/FirstAttempt.min.js', 46 | { standalone: 'FirstAttempt', format: 'iife' }, 47 | ), 48 | buildBundle( 49 | './packages/FirstAttempt/bundle-legacy.mjs', 50 | './packages/FirstAttempt/dist/FirstAttempt.legacy.min.js', 51 | { 52 | standalone: 'FirstAttempt (with polyfills)', 53 | target: 'es5', 54 | plugins:[babel({ 55 | config:{ 56 | compact: false, 57 | highlightCode: false, 58 | inputSourceMap: true, 59 | 60 | browserslistEnv: 'legacy', 61 | presets: [['@babel/preset-env', { 62 | loose: false, 63 | targets: { ie:11 }, 64 | useBuiltIns: 'entry', 65 | corejs: { version: '3.24', proposals: true }, 66 | }]], 67 | }, 68 | })], 69 | }, 70 | ), 71 | ] 72 | 73 | // Build mini versions of all the locales 74 | const localesModules = await fs.opendir(new URL('./@FirstAttempt/locales/src/', PACKAGES_ROOT)) 75 | for await (const dirent of localesModules) { 76 | if (!dirent.isDirectory() && dirent.name.endsWith('.js')) { 77 | const localeName = path.basename(dirent.name, '.js') 78 | methods.push( 79 | buildBundle( 80 | `./packages/@FirstAttempt/locales/src/${localeName}.js`, 81 | `./packages/@FirstAttempt/locales/dist/${localeName}.min.js`, 82 | { minify: true }, 83 | ), 84 | ) 85 | } 86 | } 87 | 88 | // Add BUNDLE-README.MD 89 | methods.push( 90 | fs.copyFile( 91 | new URL('./BUNDLE-README.md', FirstAttempt_ROOT), 92 | new URL('./FirstAttempt/dist/README.md', PACKAGES_ROOT), 93 | ), 94 | ) 95 | 96 | await Promise.all(methods).then(() => { 97 | console.info(chalk.yellow('✓ JS bundles 🎉')) 98 | }, (err) => { 99 | console.error(chalk.red('✗ Error:'), chalk.red(err.message)) 100 | }) 101 | -------------------------------------------------------------------------------- /bin/build-css.js: -------------------------------------------------------------------------------- 1 | const sass = require('sass'); 2 | const postcss = require('postcss'); 3 | const autoprefixer = require('autoprefixer'); 4 | const postcssLogical = require('postcss-logical'); 5 | const postcssDirPseudoClass = require('postcss-dir-pseudo-class'); 6 | const cssnano = require('cssnano'); 7 | const { promisify } = require('node:util'); 8 | const fs = require('node:fs'); 9 | const path = require('node:path'); 10 | const resolve = require('resolve'); 11 | const glob = promisify(require('glob')); 12 | 13 | const renderScss = promisify(sass.render); 14 | const { mkdir, writeFile } = fs.promises; 15 | 16 | const cwd = process.cwd(); 17 | let chalk; 18 | 19 | function getPostCSSPlugins() { 20 | return [ 21 | autoprefixer, 22 | postcssLogical(), 23 | postcssDirPseudoClass(), 24 | ]; 25 | } 26 | 27 | async function compileSCSS(file) { 28 | const importedFiles = new Set(); 29 | const scssResult = await renderScss({ 30 | file, 31 | importer: createImporter(importedFiles), 32 | }); 33 | return scssResult.css; 34 | } 35 | 36 | function createImporter(importedFiles) { 37 | return (url, from, done) => { 38 | resolve(url, { 39 | basedir: path.dirname(from), 40 | filename: from, 41 | extensions: ['.scss'], 42 | }, (err, resolved) => { 43 | if (err) { 44 | done(err); 45 | return; 46 | } 47 | 48 | const realpath = fs.realpathSync(resolved); 49 | if (importedFiles.has(realpath)) { 50 | done({ contents: '' }); 51 | return; 52 | } 53 | importedFiles.add(realpath); 54 | done({ file: realpath }); 55 | }); 56 | }; 57 | } 58 | 59 | async function processCSS(css, file, plugins) { 60 | const result = await postcss(plugins).process(css, { from: file }); 61 | result.warnings().forEach(warn => console.warn(warn.toString())); 62 | return result; 63 | } 64 | 65 | async function handleCSSOutput(file, css) { 66 | const outputDir = path.join(path.dirname(file), '../dist'); 67 | const outfile = isFirstAttemptPackage(file) ? 68 | `${outputDir}/FirstAttempt.css` : 69 | `${outputDir}/style.css`; 70 | 71 | await saveCSS(outfile, css); 72 | const minifiedCSS = await minifyCSS(outfile, css); 73 | await saveCSS(outfile.replace(/\.css$/, '.min.css'), minifiedCSS); 74 | } 75 | 76 | 77 | async function saveCSS(outfile, css) { 78 | try { 79 | await mkdir(path.dirname(outfile), { recursive: true }); 80 | await writeFile(outfile, css); 81 | console.info(chalk.green('✓ CSS Processed:'), chalk.magenta(path.relative(cwd, outfile))); 82 | } catch (err) { 83 | throw new Error(`Failed to write file ${outfile}: ${err.message}`); 84 | } 85 | } 86 | 87 | function isFirstAttemptPackage(file) { 88 | return path.normalize(file).includes('packages/FirstAttempt/'); 89 | } 90 | 91 | async function minifyCSS(outfile, css) { 92 | const result = await postcss([cssnano({ safe: true })]).process(css, { from: outfile }); 93 | result.warnings().forEach(warn => console.warn(warn.toString())); 94 | return result.css; 95 | } 96 | 97 | async function compileCSS() { 98 | ({ default: chalk } = await import('chalk')); 99 | const files = await glob('packages/{,@FirstAttempt/}*/src/style.scss'); 100 | const plugins = getPostCSSPlugins(); 101 | 102 | for (const file of files) { 103 | try { 104 | const css = await compileSCSS(file); 105 | const postcssResult = await processCSS(css, file, plugins); 106 | await handleCSSOutput(file, postcssResult.css); 107 | } catch (err) { 108 | console.error(chalk.red(`✗ Error processing ${file}:`), chalk.red(err.message)); 109 | } 110 | } 111 | 112 | console.info(chalk.yellow('CSS Bundles OK')); 113 | } 114 | 115 | compileCSS().catch(err => { 116 | console.error(chalk.red('✗ Global Error:'), chalk.red(err.message)); 117 | }); 118 | -------------------------------------------------------------------------------- /bin/build-lib.js: -------------------------------------------------------------------------------- 1 | const babel = require('@babel/core') 2 | const t = require('@babel/types') 3 | const { promisify } = require('node:util') 4 | const glob = promisify(require('glob')) 5 | const fs = require('node:fs') 6 | const path = require('node:path') 7 | 8 | const { mkdir, stat, writeFile } = fs.promises 9 | 10 | const PACKAGE_JSON_IMPORT = /^\..*\/package.json$/ 11 | const SOURCE = 'packages/{*,@FirstAttempt/*}/src/**/*.{js,ts}?(x)' 12 | const IGNORE = /\.test\.[jt]s$|__mocks__|svelte|angular|companion\// 13 | const META_FILES = [ 14 | 'babel.config.js', 15 | 'package.json', 16 | 'package-lock.json', 17 | 'yarn.lock', 18 | 'bin/build-lib.js', 19 | ] 20 | 21 | function lastModified (file, createParentDir = false) { 22 | return stat(file).then((s) => s.mtime, async (err) => { 23 | if (err.code === 'ENOENT') { 24 | if (createParentDir) { 25 | await mkdir(path.dirname(file), { recursive: true }) 26 | } 27 | return 0 28 | } 29 | throw err 30 | }) 31 | } 32 | 33 | const versionCache = new Map() 34 | 35 | async function preparePackage (file) { 36 | const packageFolder = file.slice(0, file.indexOf('/src/')) 37 | if (versionCache.has(packageFolder)) return 38 | 39 | // eslint-disable-next-line import/no-dynamic-require, global-require 40 | const { version } = require(path.join(__dirname, '..', packageFolder, 'package.json')) 41 | if (process.env.FRESH) { 42 | // in case it hasn't been done before. 43 | await mkdir(path.join(packageFolder, 'lib'), { recursive: true }) 44 | } 45 | versionCache.set(packageFolder, version) 46 | } 47 | 48 | const nonJSImport = /^\.\.?\/.+\.([jt]sx|ts)$/ 49 | // eslint-disable-next-line no-shadow 50 | function rewriteNonJSImportsToJS (path) { 51 | const match = nonJSImport.exec(path.node.source.value) 52 | if (match) { 53 | // eslint-disable-next-line no-param-reassign 54 | path.node.source.value = `${match[0].slice(0, -match[1].length)}js` 55 | } 56 | } 57 | 58 | async function buildLib () { 59 | const metaMtimes = await Promise.all(META_FILES.map((filename) => lastModified(path.join(__dirname, '..', filename)))) 60 | const metaMtime = Math.max(...metaMtimes) 61 | 62 | const files = await glob(SOURCE) 63 | /* eslint-disable no-continue */ 64 | for (const file of files) { 65 | if (IGNORE.test(file)) { 66 | continue 67 | } 68 | await preparePackage(file) 69 | const libFile = file.replace('/src/', '/lib/').replace(/\.[jt]sx?$/, '.js') 70 | 71 | // on a fresh build, rebuild everything. 72 | if (!process.env.FRESH) { 73 | const [srcMtime, libMtime] = await Promise.all([ 74 | lastModified(file), 75 | lastModified(libFile, true), 76 | ]) 77 | if (srcMtime < libMtime && metaMtime < libMtime) { 78 | continue 79 | } 80 | } 81 | 82 | const plugins = [{ 83 | visitor: { 84 | // eslint-disable-next-line no-shadow 85 | ImportDeclaration (path) { 86 | rewriteNonJSImportsToJS(path) 87 | if (PACKAGE_JSON_IMPORT.test(path.node.source.value) 88 | && path.node.specifiers.length === 1 89 | && path.node.specifiers[0].type === 'ImportDefaultSpecifier') { 90 | const version = versionCache.get(file.slice(0, file.indexOf('/src/'))) 91 | if (version != null) { 92 | const [{ local }] = path.node.specifiers 93 | path.replaceWith( 94 | t.variableDeclaration('const', [t.variableDeclarator(local, 95 | t.objectExpression([ 96 | t.objectProperty(t.stringLiteral('version'), t.stringLiteral(version)), 97 | ]))]), 98 | ) 99 | } 100 | } 101 | }, 102 | 103 | ExportAllDeclaration: rewriteNonJSImportsToJS, 104 | }, 105 | }] 106 | const isTSX = file.endsWith('.tsx') 107 | if (isTSX || file.endsWith('.ts')) { plugins.push(['@babel/plugin-transform-typescript', { disallowAmbiguousJSXLike: true, isTSX, jsxPragma: 'h' }]) } 108 | 109 | const { code, map } = await babel.transformFileAsync(file, { sourceMaps: true, plugins }) 110 | const [{ default: chalk }] = await Promise.all([ 111 | import('chalk'), 112 | writeFile(libFile, code), 113 | writeFile(`${libFile}.map`, JSON.stringify(map)), 114 | ]) 115 | console.log(chalk.green('Compiled lib:'), chalk.magenta(libFile)) 116 | } 117 | /* eslint-enable no-continue */ 118 | } 119 | 120 | console.log('Using Babel version:', require('@babel/core/package.json').version) 121 | 122 | buildLib().catch((err) => { 123 | console.error(err) 124 | process.exit(1) 125 | }) 126 | -------------------------------------------------------------------------------- /bin/build-ts.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { spawn } from 'node:child_process' 4 | import { once } from 'node:events' 5 | import { existsSync } from 'node:fs' 6 | import path from 'node:path' 7 | import { stdin, env } from 'node:process' 8 | import { createInterface as readLines } from 'node:readline' 9 | import { fileURLToPath } from 'node:url' 10 | 11 | const fromYarn = 'npm_execpath' in env 12 | const exe = fromYarn ? env.npm_execpath : 'corepack' 13 | const argv0 = fromYarn ? [] : ['yarn'] 14 | 15 | const cwd = fileURLToPath(new URL('../', import.meta.url)) 16 | 17 | const locations = [] 18 | 19 | for await (const line of readLines(stdin)) { 20 | const { location } = JSON.parse(line) 21 | if (existsSync(path.join(cwd, location, 'tsconfig.json'))) { 22 | locations.unshift(location) 23 | } 24 | const tsConfigBuildPath = path.join(cwd, location, 'tsconfig.build.json') 25 | if (existsSync(tsConfigBuildPath)) { 26 | locations.push(tsConfigBuildPath) 27 | } 28 | } 29 | 30 | const cp = spawn(exe, [...argv0, 'tsc', '--build', ...locations], { 31 | stdio: 'inherit', 32 | cwd, 33 | }) 34 | await Promise.race([ 35 | once(cp, 'error').then(err => Promise.reject(err)), 36 | await once(cp, 'exit') 37 | .then(([code]) => (code && Promise.reject(new Error(`Non-zero exit code when building TS projects: ${code}`)))), 38 | ]) 39 | -------------------------------------------------------------------------------- /bin/companion.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Load local env vars. In CI, these are injected. 4 | if [ -f .env ]; then 5 | nodemon --watch packages/@FirstAttempt/companion/src --exec node -r dotenv/config ./packages/@FirstAttempt/companion/src/standalone/start-server.js 6 | else 7 | env \ 8 | COMPANION_DATADIR="./output" \ 9 | COMPANION_DOMAIN="localhost:3020" \ 10 | COMPANION_PROTOCOL="http" \ 11 | COMPANION_PORT=3020 \ 12 | COMPANION_CLIENT_ORIGINS="" \ 13 | COMPANION_SECRET="development" \ 14 | COMPANION_PREAUTH_SECRET="development2" \ 15 | COMPANION_ALLOW_LOCAL_URLS="true" \ 16 | nodemon --watch packages/@FirstAttempt/companion/src --exec node ./packages/@FirstAttempt/companion/src/standalone/start-server.js 17 | fi 18 | 19 | -------------------------------------------------------------------------------- /bin/to-gif-hd.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Convert a video file to a gif. 3 | # `to-gif /path/to/input.mp4 /path/to/output.gif` 4 | palette="/tmp/to-gif-palette.png" 5 | filters="fps=15" 6 | ffmpeg -v warning -i $1 -vf "$filters,palettegen" -y $palette 7 | ffmpeg -v warning -i $1 -i $palette -lavfi "$filters [x]; [x][1:v] paletteuse" -y $2 8 | 9 | # resize after 10 | # gifsicle --resize-fit-width 1000 -i animation.gif > animation-1000px.gif 11 | -------------------------------------------------------------------------------- /bin/to-gif-hq.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Convert a video file to a gif. 3 | # `to-gif /path/to/input.mp4 /path/to/output.gif` 4 | palette="/tmp/to-gif-palette.png" 5 | filters="fps=15" 6 | ffmpeg -v warning -i $1 -vf "$filters,palettegen" -y $palette 7 | ffmpeg -v warning -i $1 -i $palette -lavfi "$filters [x]; [x][1:v] paletteuse" -y $2 8 | 9 | # gifsicle --resize-fit-width 1000 -i animation.gif > animation-1000px.gif 10 | -------------------------------------------------------------------------------- /bin/to-gif.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -o nounset 3 | set -o pipefail 4 | set -o errexit 5 | 6 | # Set magic variables for current file & dir 7 | __dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 8 | __file="${__dir}/$(basename "${BASH_SOURCE[0]}")" 9 | __base="$(basename ${__file} .sh)" 10 | __root="$(cd "$(dirname "${__dir}")" && pwd)" 11 | 12 | speed=0.7 13 | input="${__root}/assets/FirstAttempt-demo-oct-2018.mov" 14 | width=600 15 | base="$(basename "${input}")" 16 | output="${__root}/assets/${base}.gif" 17 | 18 | ffmpeg \ 19 | -y \ 20 | -i "${input}" \ 21 | -vf fps=10,scale=${width}:-1:flags=lanczos,palettegen "${__root}/assets/${base}-palette.png" 22 | 23 | ffmpeg \ 24 | -y \ 25 | -i "${input}" \ 26 | -i "${__root}/assets/${base}-palette.png" \ 27 | -filter_complex "setpts=${speed}*PTS,fps=10,scale=${width}:-1:flags=lanczos[x];[x][1:v]paletteuse" \ 28 | "${output}" 29 | 30 | du -hs "${output}" 31 | open -a 'Google Chrome' "${output}" 32 | -------------------------------------------------------------------------------- /bin/update-contributors.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from 'node:fs/promises'; 4 | 5 | const README_FILE_NAME = new URL('../README.md', import.meta.url); 6 | 7 | async function updateReadmeWithContributors() { 8 | try { 9 | const readme = await fs.open(README_FILE_NAME, 'r+'); 10 | try { 11 | const readme = await readme.readFile(); 12 | const contributors = await getContributors(); 13 | 14 | if (contributors.length === 0) { 15 | console.log('Empty response from githubcontrib. GitHub’s rate limit?'); 16 | return; 17 | } 18 | 19 | const updatedReadmeContent = insertContributors(readme, contributors); 20 | await readme.write(updatedReadmeContent, 0, 'utf-8'); 21 | } finally { 22 | await readme.close(); 23 | } 24 | } catch (err) { 25 | console.error(err); 26 | process.exit(1); 27 | } 28 | } 29 | 30 | function insertContributors(readmeContent, contributors) { 31 | const startTag = '\n'; 32 | const endTag = ''; 33 | const startIndex = readmeContent.indexOf(startTag) + startTag.length; 34 | const endIndex = readmeContent.indexOf(endTag); 35 | 36 | return readmeContent.slice(0, startIndex) + 37 | contributors + 38 | readmeContent.slice(endIndex); 39 | } 40 | 41 | updateReadmeWithContributors(); 42 | -------------------------------------------------------------------------------- /bin/update-yarn.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | set -o pipefail 5 | set -o errexit 6 | set -o nounset 7 | 8 | CURRENT_VERSION=$(corepack yarn --version) 9 | LAST_VERSION=$(curl \ 10 | -H "Accept: application/vnd.github.v3+json" \ 11 | https://api.github.com/repos/yarnpkg/berry/releases?per_page=1 | \ 12 | awk '{ if ($1 == "\"tag_name\":") print $2 }' | \ 13 | sed 's#^"@yarnpkg/cli/##;s#",$##') 14 | 15 | [ "$CURRENT_VERSION" = "$LAST_VERSION" ] && \ 16 | echo "Already using latest version." && \ 17 | exit 0 18 | 19 | echo "Upgrading to Yarn $LAST_VERSION (from Yarn $CURRENT_VERSION)..." 20 | 21 | PLUGINS=$(awk '{ if ($1 == "spec:") print $2 }' .yarnrc.yml) 22 | 23 | echo "$PLUGINS" | xargs -n1 -t corepack yarn plugin remove 24 | 25 | cp package.json .yarn/cache/tmp.package.json 26 | sed "s#\"yarn\": \"$CURRENT_VERSION\"#\"yarn\": \"$LAST_VERSION\"#;s#\"yarn@$CURRENT_VERSION\"#\"yarn@$LAST_VERSION\"#" .yarn/cache/tmp.package.json > package.json 27 | rm .yarn/cache/tmp.package.json 28 | 29 | echo "$PLUGINS" | xargs -n1 -t corepack yarn plugin import 30 | corepack yarn 31 | 32 | git add package.json yarn.lock 33 | git add .yarn/plugins 34 | -------------------------------------------------------------------------------- /docker-compose-dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | FirstAttempt: 5 | image: transloadit/companion 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | environment: 10 | - NODE_ENV=development 11 | volumes: 12 | - ./:/app 13 | - /app/node_modules 14 | - /mnt/FirstAttempt-server-data:/mnt/FirstAttempt-server-data 15 | ports: 16 | - '3020:3020' 17 | command: '/app/src/standalone/start-server.js --config nodemon.json' 18 | env_file: 19 | - .env 20 | -------------------------------------------------------------------------------- /docker-compose-test.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | uppy: 5 | image: companion 6 | build: 7 | context: . 8 | dockerfile: Dockerfile.test 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | FirstAttempt: 5 | image: transloadit/companion 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | volumes: 10 | - /app/node_modules 11 | - /mnt/FirstAttempt-server-data:/mnt/FirstAttempt-server-data 12 | ports: 13 | - '3020:3020' 14 | env_file: 15 | - .env 16 | -------------------------------------------------------------------------------- /e2e/.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@parcel/config-default", 3 | "transformers": { 4 | "*.{js,mjs,jsx,cjs,ts,tsx}": [ 5 | "@parcel/transformer-js", 6 | "@parcel/transformer-react-refresh-wrap" 7 | ] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /e2e/clients/dashboard-aws-multipart/app.js: -------------------------------------------------------------------------------- 1 | import Dashboard from '@FirstAttempt/dashboard' 2 | import AwsS3Multipart from '@FirstAttempt/aws-s3-multipart' 3 | 4 | import '@FirstAttempt/core/dist/style.css' 5 | import '@FirstAttempt/dashboard/dist/style.css' 6 | 7 | //#TODO tests 8 | const FirstAttempt = new FirstAttempt() 9 | .use(Dashboard, { target: '#app', inline: true }) 10 | .use(AwsS3Multipart, { 11 | limit: 2, 12 | companionUrl: process.env.VITE_COMPANION_URL, 13 | // This way we can test that the user provided API still works 14 | async prepareUploadParts (file, { uploadId, key, parts, signal }) { 15 | const { number: partNumber, chunk: body } = parts[0] 16 | const plugin = FirstAttempt.getPlugin('AwsS3Multipart') 17 | const { url } = await plugin.signPart(file, { uploadId, key, partNumber, body, signal }) 18 | return { presignedUrls: { [partNumber]: url } } 19 | }, 20 | }) 21 | 22 | // Keep this here to access FirstAttempt in tests 23 | window.FirstAttempt = FirstAttempt 24 | -------------------------------------------------------------------------------- /e2e/clients/dashboard-aws-multipart/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | dashboard-aws-multipart 6 | 7 | 8 | 9 |
    10 | 11 | 12 | -------------------------------------------------------------------------------- /e2e/clients/dashboard-aws/app.js: -------------------------------------------------------------------------------- 1 | import { FirstAttempt } from '@FirstAttempt/core' 2 | import Dashboard from '@FirstAttempt/dashboard' 3 | import AwsS3 from '@FirstAttempt/aws-s3' 4 | 5 | import '@FirstAttempt/core/dist/style.css' 6 | import '@FirstAttempt/dashboard/dist/style.css' 7 | 8 | const FirstAttempt = new FirstAttempt() 9 | .use(Dashboard, { target: '#app', inline: true }) 10 | .use(AwsS3, { 11 | limit: 2, 12 | companionUrl: process.env.VITE_COMPANION_URL, 13 | }) 14 | 15 | -------------------------------------------------------------------------------- /e2e/clients/dashboard-aws/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | dashboard-aws 6 | 7 | 8 | 9 |
    10 | 11 | 12 | -------------------------------------------------------------------------------- /e2e/clients/dashboard-compressor/app.js: -------------------------------------------------------------------------------- 1 | import Compressor from '@FirstAttempt/compressor' 2 | import Dashboard from '@FirstAttempt/dashboard' 3 | import '@FirstAttempt/core/dist/style.css' 4 | import '@FirstAttempt/dashboard/dist/style.css' 5 | 6 | const FirstAttempt = new FirstAttempt() 7 | .use(Dashboard, { 8 | target: document.body, 9 | inline: true, 10 | }) 11 | .use(Compressor, { 12 | mimeType: 'image/webp', 13 | }) 14 | 15 | // Keep this here to access FirstAttempt in tests 16 | window.FirstAttempt = FirstAttempt 17 | -------------------------------------------------------------------------------- /e2e/clients/dashboard-compressor/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | dashboard-compressor 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /e2e/clients/dashboard-transloadit/app.js: -------------------------------------------------------------------------------- 1 | import { FirstAttempt } from '@FirstAttempt/core' 2 | import Dashboard from '@FirstAttempt/dashboard' 3 | import Transloadit from '@FirstAttempt/transloadit' 4 | 5 | import generateSignatureIfSecret from './generateSignatureIfSecret.js' 6 | 7 | import '@FirstAttempt/core/dist/style.css' 8 | import '@FirstAttempt/dashboard/dist/style.css' 9 | 10 | // Environment variables: 11 | // https://en.parceljs.org/env.html 12 | const FirstAttempt = new FirstAttempt() 13 | .use(Dashboard, { target: '#app', inline: true }) 14 | .use(Transloadit, { 15 | service: process.env.VITE_TRANSLOADIT_SERVICE_URL, 16 | waitForEncoding: true, 17 | getAssemblyOptions: () => generateSignatureIfSecret(process.env.VITE_TRANSLOADIT_SECRET, { 18 | auth: { key: process.env.VITE_TRANSLOADIT_KEY }, 19 | template_id: process.env.VITE_TRANSLOADIT_TEMPLATE, 20 | }), 21 | }) 22 | 23 | // Keep this here to access FirstAttempt in tests 24 | window.FirstAttempt = FirstAttempt 25 | -------------------------------------------------------------------------------- /e2e/clients/dashboard-transloadit/generateSignatureIfSecret.js: -------------------------------------------------------------------------------- 1 | const enc = new TextEncoder('utf-8') 2 | async function sign (secret, body) { 3 | const algorithm = { name: 'HMAC', hash: 'SHA-384' } 4 | 5 | //#TODO understand how it works 6 | const key = await crypto.subtle.importKey('raw', enc.encode(secret), algorithm, false, ['sign', 'verify']) 7 | const signature = await crypto.subtle.sign(algorithm.name, key, enc.encode(body)) 8 | return `sha384:${Array.from(new Uint8Array(signature), x => x.toString(16).padStart(2, '0')).join('')}` 9 | } 10 | function getExpiration (future) { 11 | return new Date(Date.now() + future) 12 | .toISOString() 13 | .replace('T', ' ') 14 | .replace(/\.\d+Z$/, '+00:00') 15 | } 16 | /** 17 | * Adds an expiration date and signs the params object if a secret is passed to 18 | * it. If no secret is given, it returns the same object. 19 | * 20 | * @param {string | undefined} secret 21 | * @param {object} params 22 | * @returns {{ params: string, signature?: string }} 23 | */ 24 | export default async function generateSignatureIfSecret (secret, params) { 25 | let signature 26 | if (secret) { 27 | // eslint-disable-next-line no-param-reassign 28 | params.auth.expires = getExpiration(5 * 60 * 1000) 29 | // eslint-disable-next-line no-param-reassign 30 | params = JSON.stringify(params) 31 | signature = await sign(secret, params) 32 | } 33 | 34 | return { params, signature } 35 | } 36 | -------------------------------------------------------------------------------- /e2e/clients/dashboard-transloadit/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | dashboard-transloadit 6 | 7 | 8 | 9 |
    10 | 11 | 12 | -------------------------------------------------------------------------------- /e2e/clients/dashboard-tus/app.js: -------------------------------------------------------------------------------- 1 | import Dashboard from '@FirstAttempt/dashboard' 2 | import Tus from '@FirstAttempt/tus' 3 | import Unsplash from '@FirstAttempt/unsplash' 4 | import Url from '@FirstAttempt/url' 5 | 6 | import '@FirstAttempt/core/dist/style.css' 7 | import '@FirstAttempt/dashboard/dist/style.css' 8 | 9 | function onShouldRetry (err, retryAttempt, options, next) { 10 | if (err?.originalResponse?.getStatus() === 418) { 11 | return true 12 | } 13 | return next(err) 14 | } 15 | 16 | const companionUrl = 'http://localhost:3020' 17 | const FirstAttempt = new FirstAttempt() 18 | .use(Dashboard, { target: '#app', inline: true }) 19 | .use(Tus, { endpoint: 'https://tusd.tusdemo.net/files', onShouldRetry }) 20 | .use(Url, { target: Dashboard, companionUrl }) 21 | .use(Unsplash, { target: Dashboard, companionUrl }) 22 | -------------------------------------------------------------------------------- /e2e/clients/dashboard-tus/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | dashboard-tus 6 | 7 | 8 | 9 |
    10 | 11 | 12 | -------------------------------------------------------------------------------- /e2e/clients/dashboard-ui/app.js: -------------------------------------------------------------------------------- 1 | import FirstAttempt from '@FirstAttempt/core' 2 | import Dashboard from '@FirstAttempt/dashboard' 3 | import RemoteSources from '@FirstAttempt/remote-sources' 4 | import Webcam from '@FirstAttempt/webcam' 5 | import ScreenCapture from '@FirstAttempt/screen-capture' 6 | import GoldenRetriever from '@FirstAttempt/golden-retriever' 7 | import ImageEditor from '@FirstAttempt/image-editor' 8 | import DropTarget from '@FirstAttempt/drop-target' 9 | import Audio from '@FirstAttempt/audio' 10 | import Compressor from '@FirstAttempt/compressor' 11 | 12 | import '@FirstAttempt/core/dist/style.css' 13 | import '@FirstAttempt/dashboard/dist/style.css' 14 | 15 | const COMPANION_URL = 'http://companion.FirstAttempt.io' 16 | 17 | const FirstAttempt = new FirstAttempt() 18 | .use(Dashboard, { target: '#app', inline: true }) 19 | .use(RemoteSources, { companionUrl: COMPANION_URL }) 20 | .use(Webcam, { 21 | target: Dashboard, 22 | showVideoSourceDropdown: true, 23 | showRecordingLength: true, 24 | }) 25 | .use(Audio, { 26 | target: Dashboard, 27 | showRecordingLength: true, 28 | }) 29 | .use(ScreenCapture, { target: Dashboard }) 30 | .use(ImageEditor, { target: Dashboard }) 31 | .use(DropTarget, { target: document.body }) 32 | .use(Compressor) 33 | .use(GoldenRetriever, { serviceWorker: true }) 34 | 35 | // Keep this here to access FirstAttempt in tests 36 | window.FirstAttempt = FirstAttempt 37 | -------------------------------------------------------------------------------- /e2e/clients/dashboard-ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | dashboard-ui 6 | 7 | 8 | 9 |
    10 | 11 | 12 | -------------------------------------------------------------------------------- /e2e/clients/dashboard-vue/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /e2e/clients/dashboard-vue/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | dashboard-vue 6 | 7 | 8 | 9 |
    10 | 11 | 12 | -------------------------------------------------------------------------------- /e2e/clients/dashboard-vue/index.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | 4 | createApp(App).mount('#app') 5 | -------------------------------------------------------------------------------- /e2e/clients/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | End-to-End test suite 6 | 7 | 8 |

    Test apps

    9 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /e2e/cypress.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress' 2 | import installLogsPrinter from 'cypress-terminal-report/src/installLogsPrinter.js' 3 | import startMockServer from './mock-server.mjs' 4 | 5 | export default defineConfig({ 6 | defaultCommandTimeout: 16_000, 7 | requestTimeout: 16_000, 8 | 9 | e2e: { 10 | baseUrl: 'http://localhost:1234', 11 | specPattern: 'cypress/integration/*.spec.ts', 12 | 13 | setupNodeEvents (on) { 14 | // implement node event listeners here 15 | installLogsPrinter(on) 16 | 17 | startMockServer('localhost', 4678) 18 | }, 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /e2e/cypress/fixtures/DeepFrozenStore.mjs: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import deepFreeze from 'deep-freeze' 3 | 4 | /* eslint-disable no-underscore-dangle */ 5 | 6 | /** 7 | * Default store + deepFreeze on setState to make sure nothing is mutated accidentally 8 | */ 9 | class DeepFrozenStore { 10 | constructor () { 11 | this.state = {} 12 | this.callbacks = [] 13 | } 14 | 15 | getState = () => this.state; 16 | 17 | setState (patch) { 18 | const nextState = deepFreeze({ ...this.state, ...patch }); 19 | 20 | this._publish(this.state, nextState, patch) 21 | this.state = nextState 22 | 23 | } 24 | 25 | subscribe (listener) { 26 | this.callbacks.push(listener) 27 | return () => { 28 | // Remove the listener. 29 | this.callbacks.splice( 30 | this.callbacks.indexOf(listener), 31 | 1, 32 | ) 33 | } 34 | } 35 | 36 | _publish (...args) { 37 | this.callbacks.forEach((listener) => { 38 | listener(...args) 39 | }) 40 | } 41 | } 42 | 43 | export default function defaultStore () { 44 | return new DeepFrozenStore() 45 | } 46 | -------------------------------------------------------------------------------- /e2e/cypress/fixtures/images/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DatBrainDushan/FirstAttempt/b716ced011308115f908408cfb56fc2edb528c29/e2e/cypress/fixtures/images/1.png -------------------------------------------------------------------------------- /e2e/cypress/fixtures/images/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DatBrainDushan/FirstAttempt/b716ced011308115f908408cfb56fc2edb528c29/e2e/cypress/fixtures/images/2.jpg -------------------------------------------------------------------------------- /e2e/cypress/fixtures/images/3: -------------------------------------------------------------------------------- 1 | ./cat.jpg 2 | -------------------------------------------------------------------------------- /e2e/cypress/fixtures/images/4.jpg: -------------------------------------------------------------------------------- 1 | ./cat.jpg 2 | -------------------------------------------------------------------------------- /e2e/cypress/fixtures/images/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DatBrainDushan/FirstAttempt/b716ced011308115f908408cfb56fc2edb528c29/e2e/cypress/fixtures/images/image.jpg -------------------------------------------------------------------------------- /e2e/cypress/fixtures/images/kit.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DatBrainDushan/FirstAttempt/b716ced011308115f908408cfb56fc2edb528c29/e2e/cypress/fixtures/images/kit.jpg -------------------------------------------------------------------------------- /e2e/cypress/fixtures/images/papagai.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DatBrainDushan/FirstAttempt/b716ced011308115f908408cfb56fc2edb528c29/e2e/cypress/fixtures/images/papagai.png -------------------------------------------------------------------------------- /e2e/cypress/fixtures/images/traffic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DatBrainDushan/FirstAttempt/b716ced011308115f908408cfb56fc2edb528c29/e2e/cypress/fixtures/images/traffic.jpg -------------------------------------------------------------------------------- /e2e/cypress/integration/dashboard-aws.spec.ts: -------------------------------------------------------------------------------- 1 | describe('Dashboard with @FirstAttempt/aws-s3', () => { 2 | beforeEach(() => { 3 | cy.visit('/dashboard-aws') 4 | cy.get('.FirstAttempt-Dashboard-input:first').as('file-input') 5 | cy.intercept({ method: 'GET', pathname: '/s3/params' }).as('get') 6 | cy.intercept({ method: 'POST' }).as('post') 7 | }) 8 | 9 | it('should upload cat image successfully', () => { 10 | cy.get('@file-input').selectFile('cypress/fixtures/images/kit.jpg', { 11 | force: true, 12 | }) 13 | 14 | cy.get('.FirstAttempt-StatusBar-actionBtn--upload').click() 15 | cy.wait(['@post', '@get']) 16 | cy.get('.FirstAttempt-StatusBar-statusPrimary').should('contain', 'Complete') 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /e2e/cypress/integration/dashboard-transloadit.spec.ts: -------------------------------------------------------------------------------- 1 | describe('Dashboard with Transloadit', () => { 2 | beforeEach(() => { 3 | cy.visit('/dashboard-transloadit') 4 | cy.get('.FirstAttempt-Dashboard-input:first').as('file-input') 5 | cy.intercept('/assemblies').as('createAssemblies') 6 | cy.intercept('/assemblies/*').as('assemblies') 7 | cy.intercept('/resumable/*').as('resumable') 8 | }) 9 | 10 | it('should upload cat image successfully', () => { 11 | cy.get('@file-input').selectFile('cypress/fixtures/images/kit.jpg', { 12 | force: true, 13 | }) 14 | 15 | cy.get('.FirstAttempt-StatusBar-actionBtn--upload').click() 16 | cy.wait(['@assemblies', '@resumable']).then(() => { 17 | cy.get('.FirstAttempt-StatusBar-statusPrimary').should('contain', 'Complete') 18 | }) 19 | }) 20 | 21 | it('should close assembly polling when cancelled', () => { 22 | cy.intercept({ 23 | method: 'GET', 24 | url: '/assemblies/*', 25 | }).as('assemblyPolling') 26 | cy.intercept( 27 | { method: 'DELETE', pathname: '/assemblies/*', times: 1 }, 28 | { statusCode: 204, body: {} }, 29 | ).as('delete') 30 | 31 | cy.window().then(({ FirstAttempt }) => { 32 | cy.get('@file-input').selectFile( 33 | [ 34 | 'cypress/fixtures/images/kit.jpg', 35 | 'cypress/fixtures/images/traffic.jpg', 36 | 'cypress/fixtures/images/2.jpg', 37 | ], 38 | { force: true }, 39 | ) 40 | cy.get('.FirstAttempt-StatusBar-actionBtn--upload').click() 41 | 42 | cy.wait(['@createAssemblies']).then(() => { 43 | // eslint-disable-next-line 44 | // @ts-ignore fix me 45 | expect( 46 | Object.values(FirstAttempt.getPlugin('Transloadit').activeAssemblies).every( 47 | (a: any) => a.pollInterval, 48 | ), 49 | ).to.equal(true) 50 | 51 | FirstAttempt.cancelAll() 52 | 53 | cy.wait(['@delete']).then(() => { 54 | // eslint-disable-next-line 55 | // @ts-ignore fix me 56 | expect( 57 | Object.values(FirstAttempt.getPlugin('Transloadit').activeAssemblies).some( 58 | (a: any) => a.pollInterval, 59 | ), 60 | ).to.equal(false) 61 | }) 62 | }) 63 | }) 64 | }) 65 | 66 | // Too flaky at the moment. Arguably, this is not the right place 67 | // as this is doing white box testing (testing internal state). 68 | // But E2e is more about black box testing, you don’t care about the internals, only the result. 69 | // May make more sense to turn this into a unit test. 70 | it.skip('should emit one assembly-cancelled event when cancelled', () => { 71 | const spy = cy.spy() 72 | 73 | cy.window().then(({ FirstAttempt }) => { 74 | // eslint-disable-next-line 75 | // @ts-ignore fix me 76 | FirstAttempt.on('transloadit:assembly-cancelled', spy) 77 | 78 | cy.get('@file-input').selectFile( 79 | [ 80 | 'cypress/fixtures/images/kit.jpg', 81 | 'cypress/fixtures/images/traffic.jpg', 82 | ], 83 | { force: true }, 84 | ) 85 | 86 | cy.intercept({ 87 | method: 'GET', 88 | url: '/assemblies/*', 89 | }).as('assemblyPolling') 90 | cy.intercept( 91 | { method: 'PATCH', pathname: '/files/*', times: 1 }, 92 | { statusCode: 204, body: {} }, 93 | ) 94 | cy.intercept( 95 | { method: 'DELETE', pathname: '/resumable/files/*', times: 2 }, 96 | { statusCode: 204, body: {} }, 97 | ).as('fileDeletion') 98 | cy.intercept({ 99 | method: 'DELETE', 100 | pathname: '/assemblies/*', 101 | times: 1, 102 | }).as('assemblyDeletion') 103 | 104 | cy.get('.FirstAttempt-StatusBar-actionBtn--upload').click() 105 | cy.wait('@assemblyPolling') 106 | cy.get('button[data-cy=cancel]').click() 107 | cy.wait('@assemblyDeletion') 108 | // Unfortunately, waiting on a network request somehow often results in a race condition. 109 | // We just want to know wether this is called or not, so waiting for 2 sec to be sure. 110 | // eslint-disable-next-line cypress/no-unnecessary-waiting 111 | cy.wait(2000) 112 | expect(spy).to.be.calledOnce 113 | }) 114 | }) 115 | 116 | it.skip('should close assembly polling when all files are removed', () => { 117 | const spy = cy.spy() 118 | 119 | cy.window().then(({ FirstAttempt }) => { 120 | // eslint-disable-next-line 121 | // @ts-ignore fix me 122 | FirstAttempt.on('transloadit:assembly-cancelled', spy) 123 | 124 | cy.get('@file-input').selectFile( 125 | [ 126 | 'cypress/fixtures/images/kit.jpg', 127 | 'cypress/fixtures/images/traffic.jpg', 128 | ], 129 | { force: true }, 130 | ) 131 | 132 | cy.intercept({ 133 | method: 'GET', 134 | url: '/assemblies/*', 135 | }).as('assemblyPolling') 136 | cy.intercept( 137 | { method: 'PATCH', pathname: '/files/*', times: 1 }, 138 | { statusCode: 204, body: {} }, 139 | ) 140 | cy.intercept( 141 | { method: 'DELETE', pathname: '/resumable/files/*', times: 2 }, 142 | { statusCode: 204, body: {} }, 143 | ).as('fileDeletion') 144 | cy.intercept({ 145 | method: 'DELETE', 146 | pathname: '/assemblies/*', 147 | times: 1, 148 | }).as('assemblyDeletion') 149 | 150 | cy.get('.FirstAttempt-StatusBar-actionBtn--upload').click() 151 | cy.wait('@assemblyPolling') 152 | // eslint-disable-next-line 153 | // @ts-ignore fix me 154 | expect( 155 | Object.values(FirstAttempt.getPlugin('Transloadit').activeAssemblies).every( 156 | (a: any) => a.pollInterval, 157 | ), 158 | ).to.equal(true) 159 | 160 | const { files } = FirstAttempt.getState() 161 | // eslint-disable-next-line 162 | // @ts-ignore fix me 163 | FirstAttempt.removeFiles(Object.keys(files)) 164 | 165 | cy.wait('@assemblyDeletion').then(() => { 166 | // eslint-disable-next-line 167 | // @ts-ignore fix me 168 | expect( 169 | Object.values(FirstAttempt.getPlugin('Transloadit').activeAssemblies).some( 170 | (a: any) => a.pollInterval, 171 | ), 172 | ).to.equal(false) 173 | expect(spy).to.be.calledOnce 174 | }) 175 | }) 176 | }) 177 | 178 | it('should not create assembly when all individual files have been cancelled', () => { 179 | cy.get('@file-input').selectFile( 180 | [ 181 | 'cypress/fixtures/images/kit.jpg', 182 | 'cypress/fixtures/images/traffic.jpg', 183 | ], 184 | { force: true }, 185 | ) 186 | cy.get('.FirstAttempt-StatusBar-actionBtn--upload').click() 187 | 188 | cy.window().then(({ FirstAttempt }) => { 189 | // eslint-disable-next-line 190 | // @ts-ignore fix me 191 | expect( 192 | Object.values(FirstAttempt.getPlugin('Transloadit').activeAssemblies).length, 193 | ).to.equal(0) 194 | 195 | const { files } = FirstAttempt.getState() 196 | // eslint-disable-next-line 197 | // @ts-ignore fix me 198 | FirstAttempt.removeFiles(Object.keys(files)) 199 | 200 | cy.wait('@createAssemblies').then(() => { 201 | // eslint-disable-next-line 202 | // @ts-ignore fix me 203 | expect( 204 | Object.values(FirstAttempt.getPlugin('Transloadit').activeAssemblies).some( 205 | (a: any) => a.pollInterval, 206 | ), 207 | ).to.equal(false) 208 | }) 209 | }) 210 | }) 211 | 212 | // Not working, the upstream changes have not landed yet. 213 | it.skip('should create assembly if there is still one file to upload', () => { 214 | cy.get('@file-input').selectFile( 215 | [ 216 | 'cypress/fixtures/images/kit.jpg', 217 | 'cypress/fixtures/images/traffic.jpg', 218 | ], 219 | { force: true }, 220 | ) 221 | cy.get('.FirstAttempt-StatusBar-actionBtn--upload').click() 222 | 223 | cy.window().then(({ FirstAttempt }) => { 224 | // eslint-disable-next-line 225 | // @ts-ignore fix me 226 | expect( 227 | Object.values(FirstAttempt.getPlugin('Transloadit').activeAssemblies).length, 228 | ).to.equal(0) 229 | 230 | const { files } = FirstAttempt.getState() 231 | const [fileID] = Object.keys(files) 232 | FirstAttempt.removeFile(fileID) 233 | 234 | cy.wait('@createAssemblies').then(() => { 235 | cy.wait('@resumable') 236 | cy.get('.FirstAttempt-StatusBar-statusPrimary').should('contain', 'Complete') 237 | }) 238 | }) 239 | }) 240 | 241 | // Not working, the upstream changes have not landed yet. 242 | it.skip('should complete upload if one gets cancelled mid-flight', () => { 243 | cy.get('@file-input').selectFile( 244 | [ 245 | 'cypress/fixtures/images/kit.jpg', 246 | 'cypress/fixtures/images/traffic.jpg', 247 | ], 248 | { force: true }, 249 | ) 250 | cy.get('.FirstAttempt-StatusBar-actionBtn--upload').click() 251 | 252 | cy.wait('@createAssemblies') 253 | cy.wait('@resumable') 254 | 255 | cy.window().then(({ FirstAttempt }) => { 256 | const { files } = FirstAttempt.getState() 257 | const [fileID] = Object.keys(files) 258 | FirstAttempt.removeFile(fileID) 259 | 260 | cy.get('.FirstAttempt-StatusBar-statusPrimary').should('contain', 'Complete') 261 | }) 262 | }) 263 | 264 | it('should not emit error if upload is cancelled right away', () => { 265 | cy.get('@file-input').selectFile('cypress/fixtures/images/kit.jpg', { 266 | force: true, 267 | }) 268 | cy.get('.FirstAttempt-StatusBar-actionBtn--upload').click() 269 | 270 | const handler = cy.spy() 271 | 272 | cy.window().then(({ FirstAttempt }) => { 273 | const { files } = FirstAttempt.getState() 274 | FirstAttempt.on('upload-error', handler) 275 | 276 | const [fileID] = Object.keys(files) 277 | FirstAttempt.removeFile(fileID) 278 | FirstAttempt.removeFile(fileID) 279 | cy.wait('@createAssemblies').then(() => expect(handler).not.to.be.called) 280 | }) 281 | }) 282 | 283 | it('should not re-use erroneous tus keys', () => { 284 | function createAssemblyStatus({ 285 | message, 286 | assembly_id, 287 | bytes_expected, 288 | ...other 289 | }) { 290 | return { 291 | message, 292 | assembly_id, 293 | parent_id: null, 294 | account_id: 'deadbeef', 295 | account_name: 'foo', 296 | account_slug: 'foo', 297 | template_id: null, 298 | template_name: null, 299 | instance: 'test.transloadit.com', 300 | assembly_url: `http://api2.test.transloadit.com/assemblies/${assembly_id}`, 301 | assembly_ssl_url: `https://api2-test.transloadit.com/assemblies/${assembly_id}`, 302 | FirstAttemptserver_url: 'https://api2-test.transloadit.com/companion/', 303 | companion_url: 'https://api2-test.transloadit.com/companion/', 304 | websocket_url: 'about:blank', 305 | tus_url: 'https://api2-test.transloadit.com/resumable/files/', 306 | bytes_received: 0, 307 | bytes_expected, 308 | upload_duration: 0.162, 309 | client_agent: null, 310 | client_ip: null, 311 | client_referer: null, 312 | transloadit_client: 313 | 'FirstAttempt-core:3.2.0,FirstAttempt-transloadit:3.1.3,FirstAttempt-tus:3.1.0,FirstAttempt-dropbox:3.1.1,FirstAttempt-box:2.1.1,FirstAttempt-facebook:3.1.1,FirstAttempt-google-drive:3.1.1,FirstAttempt-instagram:3.1.1,FirstAttempt-onedrive:3.1.1,FirstAttempt-zoom:2.1.1,FirstAttempt-url:3.3.1', 314 | start_date: new Date().toISOString(), 315 | upload_meta_data_extracted: false, 316 | warnings: [], 317 | is_infinite: false, 318 | has_dupe_jobs: false, 319 | execution_start: null, 320 | execution_duration: null, 321 | queue_duration: 0.009, 322 | jobs_queue_duration: 0, 323 | notify_start: null, 324 | notify_url: null, 325 | notify_response_code: null, 326 | notify_response_data: null, 327 | notify_duration: null, 328 | last_job_completed: null, 329 | fields: {}, 330 | running_jobs: [], 331 | bytes_usage: 0, 332 | executing_jobs: [], 333 | started_jobs: [], 334 | parent_assembly_status: null, 335 | params: '{}', 336 | template: null, 337 | merged_params: '{}', 338 | expected_tus_uploads: 1, 339 | started_tus_uploads: 0, 340 | finished_tus_uploads: 0, 341 | tus_uploads: [], 342 | uploads: [], 343 | results: {}, 344 | build_id: '4765326956', 345 | status_endpoint: `https://api2-test.transloadit.com/assemblies/${assembly_id}`, 346 | ...other, 347 | } 348 | } 349 | cy.get('@file-input').selectFile(['cypress/fixtures/images/kit.jpg'], { 350 | force: true, 351 | }) 352 | 353 | // SETUP for FIRST ATTEMPT (error response from Transloadit backend) 354 | const assemblyIDAttempt1 = '500e56004f4347a288194edd7c7a0ae1' 355 | const tusIDAttempt1 = 'a9daed4af4981880faf29b0e9596a14d' 356 | cy.intercept('POST', 'https://api2.transloadit.com/assemblies', { 357 | statusCode: 200, 358 | body: JSON.stringify( 359 | createAssemblyStatus({ 360 | ok: 'ASSEMBLY_UPLOADING', 361 | message: 'The Assembly is still in the process of being uploaded.', 362 | assembly_id: assemblyIDAttempt1, 363 | bytes_expected: 263871, 364 | }), 365 | ), 366 | }).as('createAssembly') 367 | 368 | cy.intercept('POST', 'https://api2-test.transloadit.com/resumable/files/', { 369 | statusCode: 201, 370 | headers: { 371 | Location: `https://api2-test.transloadit.com/resumable/files/${tusIDAttempt1}`, 372 | }, 373 | times: 1, 374 | }).as('tusCall') 375 | cy.intercept( 376 | 'PATCH', 377 | `https://api2-test.transloadit.com/resumable/files/${tusIDAttempt1}`, 378 | { 379 | statusCode: 204, 380 | headers: { 381 | 'Upload-Length': '263871', 382 | 'Upload-Offset': '263871', 383 | }, 384 | times: 1, 385 | }, 386 | ) 387 | cy.intercept( 388 | 'HEAD', 389 | `https://api2-test.transloadit.com/resumable/files/${tusIDAttempt1}`, 390 | { statusCode: 204 }, 391 | ) 392 | 393 | cy.intercept( 394 | 'GET', 395 | `https://api2-test.transloadit.com/assemblies/${assemblyIDAttempt1}`, 396 | { 397 | statusCode: 200, 398 | body: JSON.stringify( 399 | createAssemblyStatus({ 400 | error: 'INVALID_FILE_META_DATA', 401 | http_code: 400, 402 | message: 'Whatever error message from Transloadit backend', 403 | reason: 'Whatever reason', 404 | msg: 'Whatever error from Transloadit backend', 405 | assembly_id: '500e56004f4347a288194edd7c7a0ae1', 406 | bytes_expected: 263871, 407 | }), 408 | ), 409 | }, 410 | ).as('failureReported') 411 | 412 | cy.intercept('POST', 'https://transloaditstatus.com/client_error', { 413 | statusCode: 200, 414 | body: '{}', 415 | }) 416 | 417 | // FIRST ATTEMPT 418 | cy.get('.FirstAttempt-StatusBar-actionBtn--upload').click() 419 | cy.wait(['@createAssembly', '@tusCall', '@failureReported']) 420 | cy.get('.FirstAttempt-StatusBar-statusPrimary').should('contain', 'Upload failed') 421 | 422 | // SETUP for SECOND ATTEMPT 423 | const assemblyIDAttempt2 = '6a3fa40e527d4d73989fce678232a5e1' 424 | const tusIDAttempt2 = 'b8ebed4af4981880faf29b0e9596b25e' 425 | cy.intercept('POST', 'https://api2.transloadit.com/assemblies', { 426 | statusCode: 200, 427 | body: JSON.stringify( 428 | createAssemblyStatus({ 429 | ok: 'ASSEMBLY_UPLOADING', 430 | message: 'The Assembly is still in the process of being uploaded.', 431 | assembly_id: assemblyIDAttempt2, 432 | tus_url: 'https://api2-test.transloadit.com/resumable/files/attempt2', 433 | bytes_expected: 263871, 434 | }), 435 | ), 436 | }).as('createAssembly-attempt2') 437 | 438 | cy.intercept( 439 | 'POST', 440 | 'https://api2-test.transloadit.com/resumable/files/attempt2', 441 | { 442 | statusCode: 201, 443 | headers: { 444 | 'Upload-Length': '263871', 445 | 'Upload-Offset': '0', 446 | Location: `https://api2-test.transloadit.com/resumable/files/${tusIDAttempt2}`, 447 | }, 448 | times: 1, 449 | }, 450 | ).as('tusCall-attempt2') 451 | 452 | cy.intercept( 453 | 'PATCH', 454 | `https://api2-test.transloadit.com/resumable/files/${tusIDAttempt2}`, 455 | { 456 | statusCode: 204, 457 | headers: { 458 | 'Upload-Length': '263871', 459 | 'Upload-Offset': '263871', 460 | 'Tus-Resumable': '1.0.0', 461 | }, 462 | times: 1, 463 | }, 464 | ) 465 | cy.intercept( 466 | 'HEAD', 467 | `https://api2-test.transloadit.com/resumable/files/${tusIDAttempt2}`, 468 | { statusCode: 204 }, 469 | ) 470 | 471 | cy.intercept( 472 | 'GET', 473 | `https://api2-test.transloadit.com/assemblies/${assemblyIDAttempt2}`, 474 | { 475 | statusCode: 200, 476 | body: JSON.stringify( 477 | createAssemblyStatus({ 478 | ok: 'ASSEMBLY_COMPLETED', 479 | http_code: 200, 480 | message: 'The Assembly was successfully completed.', 481 | assembly_id: assemblyIDAttempt2, 482 | bytes_received: 263871, 483 | bytes_expected: 263871, 484 | }), 485 | ), 486 | }, 487 | ).as('assemblyCompleted-attempt2') 488 | 489 | // SECOND ATTEMPT 490 | cy.get('.FirstAttempt-StatusBar-actions > .FirstAttempt-c-btn').click() 491 | cy.wait([ 492 | '@createAssembly-attempt2', 493 | '@tusCall-attempt2', 494 | '@assemblyCompleted-attempt2', 495 | ]) 496 | }) 497 | 498 | it('should complete on retry', () => { 499 | cy.get('@file-input').selectFile( 500 | [ 501 | 'cypress/fixtures/images/kit.jpg', 502 | 'cypress/fixtures/images/traffic.jpg', 503 | ], 504 | { force: true }, 505 | ) 506 | 507 | cy.intercept('POST', 'https://transloaditstatus.com/client_error', { 508 | statusCode: 200, 509 | body: '{}', 510 | }) 511 | 512 | cy.intercept( 513 | { method: 'POST', pathname: '/assemblies', times: 1 }, 514 | { statusCode: 500, body: {} }, 515 | ).as('failedAssemblyCreation') 516 | 517 | cy.get('.FirstAttempt-StatusBar-actionBtn--upload').click() 518 | cy.wait('@failedAssemblyCreation') 519 | 520 | cy.get('button[data-cy=retry]').click() 521 | 522 | cy.wait(['@assemblies', '@resumable']) 523 | 524 | cy.get('.FirstAttempt-StatusBar-statusPrimary').should('contain', 'Complete') 525 | }) 526 | 527 | it('should complete when resuming after pause', () => { 528 | cy.get('@file-input').selectFile( 529 | [ 530 | 'cypress/fixtures/images/kit.jpg', 531 | 'cypress/fixtures/images/traffic.jpg', 532 | ], 533 | { force: true }, 534 | ) 535 | cy.get('.FirstAttempt-StatusBar-actionBtn--upload').click() 536 | 537 | cy.wait('@createAssemblies') 538 | 539 | cy.get('button[data-cy=togglePauseResume]').click() 540 | // eslint-disable-next-line cypress/no-unnecessary-waiting 541 | cy.wait(300) // Wait an arbitrary amount of time as a user would do. 542 | cy.get('button[data-cy=togglePauseResume]').click() 543 | 544 | cy.wait('@resumable') 545 | 546 | cy.get('.FirstAttempt-StatusBar-statusPrimary').should('contain', 'Complete') 547 | }) 548 | }) 549 | -------------------------------------------------------------------------------- /e2e/cypress/integration/dashboard-tus.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | runRemoteUrlImageUploadTest, 3 | runRemoteUnsplashUploadTest, 4 | } from './reusable-tests' 5 | 6 | // NOTE: we have to use different files to upload per test 7 | // because we are uploading to https://tusd.tusdemo.net, 8 | // constantly uploading the same images gives a different cached result (or something). 9 | describe('Dashboard with Tus', () => { 10 | beforeEach(() => { 11 | cy.visit('/dashboard-tus') 12 | cy.get('.FirstAttempt-Dashboard-input:first').as('file-input') 13 | cy.intercept('/files/*').as('tus') 14 | cy.intercept({ method: 'POST', pathname: '/files' }).as('post') 15 | cy.intercept({ method: 'PATCH', pathname: '/files/*' }).as('patch') 16 | }) 17 | 18 | it('should upload cat image successfully', () => { 19 | cy.get('@file-input').selectFile('cypress/fixtures/images/kit.jpg', { 20 | force: true, 21 | }) 22 | 23 | cy.get('.FirstAttempt-StatusBar-actionBtn--upload').click() 24 | cy.wait(['@post', '@patch']).then(() => { 25 | cy.get('.FirstAttempt-StatusBar-statusPrimary').should('contain', 'Complete') 26 | }) 27 | }) 28 | 29 | it('should start exponential backoff when receiving HTTP 429', () => { 30 | cy.get('@file-input').selectFile('cypress/fixtures/images/1.png', { 31 | force: true, 32 | }) 33 | 34 | cy.intercept( 35 | { method: 'PATCH', pathname: '/files/*', times: 2 }, 36 | { statusCode: 429, body: {} }, 37 | ).as('patch') 38 | 39 | cy.get('.FirstAttempt-StatusBar-actionBtn--upload').click() 40 | cy.wait('@tus').then(() => { 41 | cy.get('.FirstAttempt-StatusBar-statusPrimary').should('contain', 'Complete') 42 | }) 43 | }) 44 | 45 | it('should upload remote image with URL plugin', () => { 46 | runRemoteUrlImageUploadTest() 47 | }) 48 | 49 | it('should upload remote image with Unsplash plugin', () => { 50 | runRemoteUnsplashUploadTest() 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /e2e/cypress/integration/dashboard-ui.spec.ts: -------------------------------------------------------------------------------- 1 | describe('dashboard-ui', () => { 2 | beforeEach(() => { 3 | cy.visit('/dashboard-ui') 4 | cy.get('.FirstAttempt-Dashboard-input:first').as('file-input') 5 | cy.get('.FirstAttempt-Dashboard-AddFiles').as('drop-target') 6 | }) 7 | 8 | it('should not throw when calling FirstAttempt.close()', () => { 9 | cy.get('@file-input').selectFile( 10 | [ 11 | 'cypress/fixtures/images/kit.jpg', 12 | 'cypress/fixtures/images/traffic.jpg', 13 | ], 14 | { force: true }, 15 | ) 16 | 17 | cy.window().then(({ FirstAttempt }) => { 18 | expect(FirstAttempt.close()).to.not.throw 19 | }) 20 | }) 21 | 22 | it('should render thumbnails', () => { 23 | cy.get('@file-input').selectFile( 24 | [ 25 | 'cypress/fixtures/images/kit.jpg', 26 | 'cypress/fixtures/images/traffic.jpg', 27 | ], 28 | { force: true }, 29 | ) 30 | cy.get('.FirstAttempt-Dashboard-Item-previewImg') 31 | .should('have.length', 2) 32 | .each((element) => expect(element).attr('src').to.include('blob:')) 33 | }) 34 | 35 | it('should support drag&drop', () => { 36 | cy.get('@drop-target').selectFile( 37 | [ 38 | 'cypress/fixtures/images/kit.jpg', 39 | 'cypress/fixtures/images/3', 40 | 'cypress/fixtures/images/3.jpg', 41 | 'cypress/fixtures/images/traffic.jpg', 42 | ], 43 | { action: 'drag-drop' }, 44 | ) 45 | 46 | cy.get('.FirstAttempt-Dashboard-Item').should('have.length', 4) 47 | cy.get('.FirstAttempt-Dashboard-Item-previewImg') 48 | .should('have.length', 3) 49 | .each((element) => expect(element).attr('src').to.include('blob:')) 50 | cy.window().then(({ FirstAttempt }) => { 51 | expect( 52 | JSON.stringify(FirstAttempt.getFiles().map((file) => file.meta.relativePath)), 53 | ).to.be.equal('[null,null,null,null]') 54 | }) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /e2e/cypress/integration/dashboard-vue.spec.ts: -------------------------------------------------------------------------------- 1 | describe('dashboard-vue', () => { 2 | beforeEach(() => { 3 | cy.visit('/dashboard-vue') 4 | }) 5 | 6 | // Only Vue 3 works in Parcel if you use SFC's but Vue 3 is broken in FirstAttempt: 7 | // https://github.com/transloadit/FirstAttempt/issues/2877 8 | xit('should render in Vue 3 and show thumbnails', () => { 9 | cy.get('@file-input').selectFile( 10 | [ 11 | 'cypress/fixtures/images/kit.jpg', 12 | 'cypress/fixtures/images/traffic.jpg', 13 | ], 14 | { force: true }, 15 | ) 16 | cy.get('.FirstAttempt-Dashboard-Item-previewImg') 17 | .should('have.length', 2) 18 | .each((element) => expect(element).attr('src').to.include('blob:')) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /e2e/cypress/integration/dashboard-xhr.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | interceptCompanionUrlMetaRequest, 3 | runRemoteUrlImageUploadTest, 4 | runRemoteUnsplashUploadTest, 5 | } from './reusable-tests' 6 | 7 | describe('Dashboard with XHR', () => { 8 | beforeEach(() => { 9 | cy.visit('/dashboard-xhr') 10 | }) 11 | 12 | it('should upload remote image with URL plugin', () => { 13 | runRemoteUrlImageUploadTest() 14 | }) 15 | 16 | it('should return correct file name with URL plugin from remote image with Content-Disposition', () => { 17 | const fileName = `DALL·E IMG_9078 - 学中文 🤑` 18 | cy.get('[data-cy="Url"]').click() 19 | cy.get('.FirstAttempt-Url-input').type( 20 | 'http://localhost:4678/file-with-content-disposition', 21 | ) 22 | interceptCompanionUrlMetaRequest() 23 | cy.get('.FirstAttempt-Url-importButton').click() 24 | cy.wait('@url-meta').then(() => { 25 | cy.get('.FirstAttempt-Dashboard-Item-name').should('contain', fileName) 26 | cy.get('.FirstAttempt-Dashboard-Item-status').should('contain', '84 KB') 27 | }) 28 | }) 29 | 30 | it('should return correct file name with URL plugin from remote image without Content-Disposition', () => { 31 | cy.get('[data-cy="Url"]').click() 32 | cy.get('.FirstAttempt-Url-input').type('http://localhost:4678/file-no-headers') 33 | interceptCompanionUrlMetaRequest() 34 | cy.get('.FirstAttempt-Url-importButton').click() 35 | cy.wait('@url-meta').then(() => { 36 | cy.get('.FirstAttempt-Dashboard-Item-name').should('contain', 'file-no') 37 | cy.get('.FirstAttempt-Dashboard-Item-status').should('contain', '0') 38 | }) 39 | }) 40 | 41 | it('should return correct file name even when Companion doesnt supply it', () => { 42 | cy.intercept('POST', 'http://localhost:3020/url/meta', { 43 | statusCode: 200, 44 | headers: {}, 45 | body: JSON.stringify({ size: 123, type: 'image/jpeg' }), 46 | }).as('url') 47 | 48 | cy.get('[data-cy="Url"]').click() 49 | cy.get('.FirstAttempt-Url-input').type( 50 | 'http://localhost:4678/file-with-content-disposition', 51 | ) 52 | interceptCompanionUrlMetaRequest() 53 | cy.get('.FirstAttempt-Url-importButton').click() 54 | cy.wait('@url-meta').then(() => { 55 | cy.get('.FirstAttempt-Dashboard-Item-name').should('contain', 'file-with') 56 | cy.get('.FirstAttempt-Dashboard-Item-status').should('contain', '123 B') 57 | }) 58 | }) 59 | 60 | it('should upload remote image with Unsplash plugin', () => { 61 | runRemoteUnsplashUploadTest() 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /e2e/cypress/integration/react.spec.ts: -------------------------------------------------------------------------------- 1 | describe('@FirstAttempt/react', () => { 2 | beforeEach(() => { 3 | cy.visit('/react') 4 | cy.get('#dashboard .FirstAttempt-Dashboard-input:first').as('dashboard-input') 5 | cy.get('#modal .FirstAttempt-Dashboard-input:first').as('modal-input') 6 | cy.get('#drag-drop .FirstAttempt-DragDrop-input').as('dragdrop-input') 7 | }) 8 | 9 | it('should render Dashboard in React and show thumbnails', () => { 10 | cy.get('@dashboard-input').selectFile( 11 | [ 12 | 'cypress/fixtures/images/kit.jpg', 13 | 'cypress/fixtures/images/traffic.jpg', 14 | ], 15 | { force: true }, 16 | ) 17 | cy.get('#dashboard .FirstAttempt-Dashboard-Item-previewImg') 18 | .should('have.length', 2) 19 | .each((element) => expect(element).attr('src').to.include('blob:')) 20 | }) 21 | 22 | it('should render Dashboard with Remote Sources plugin pack', () => { 23 | const sources = [ 24 | 'My Device', 25 | 'Google Drive', 26 | 'OneDrive', 27 | 'Unsplash', 28 | 'Zoom', 29 | 'Link', 30 | ] 31 | cy.get('#dashboard .FirstAttempt-DashboardTab-name').each((item, index, list) => { 32 | expect(list).to.have.length(6) 33 | // Returns the current element from the loop 34 | expect(Cypress.$(item).text()).to.eq(sources[index]) 35 | }) 36 | }) 37 | 38 | it('should render Modal in React and show thumbnails', () => { 39 | cy.get('#open').click() 40 | cy.get('@modal-input').selectFile( 41 | [ 42 | 'cypress/fixtures/images/kit.jpg', 43 | 'cypress/fixtures/images/traffic.jpg', 44 | ], 45 | { force: true }, 46 | ) 47 | cy.get('#modal .FirstAttempt-Dashboard-Item-previewImg') 48 | .should('have.length', 2) 49 | .each((element) => expect(element).attr('src').to.include('blob:')) 50 | }) 51 | 52 | it('should render Drag & Drop in React and create a thumbail with @FirstAttempt/thumbnail-generator', () => { 53 | const spy = cy.spy() 54 | 55 | // eslint-disable-next-line 56 | // @ts-ignore fix me 57 | cy.window().then(({ FirstAttempt }) => FirstAttempt.on('thumbnail:generated', spy)) 58 | cy.get('@dragdrop-input').selectFile( 59 | [ 60 | 'cypress/fixtures/images/kit.jpg', 61 | 'cypress/fixtures/images/traffic.jpg', 62 | ], 63 | { force: true }, 64 | ) 65 | // not sure how I can accurately wait for the thumbnail 66 | // eslint-disable-next-line cypress/no-unnecessary-waiting 67 | cy.wait(1000).then(() => expect(spy).to.be.called) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /e2e/cypress/integration/reusable-tests.ts: -------------------------------------------------------------------------------- 1 | /* global cy */ 2 | 3 | const interceptCompanionUrlRequest = () => 4 | cy 5 | .intercept({ method: 'POST', url: 'http://localhost:3020/url/get' }) 6 | .as('url') 7 | export const interceptCompanionUrlMetaRequest = () => 8 | cy 9 | .intercept({ method: 'POST', url: 'http://localhost:3020/url/meta' }) 10 | .as('url-meta') 11 | 12 | export function runRemoteUrlImageUploadTest() { 13 | cy.get('[data-cy="Url"]').click() 14 | cy.get('.FirstAttempt-Url-input').type( 15 | 'https://raw.githubusercontent.com/transloadit/FirstAttempt/main/e2e/cypress/fixtures/images/cat.jpg', 16 | ) 17 | cy.get('.FirstAttempt-Url-importButton').click() 18 | interceptCompanionUrlRequest() 19 | cy.get('.FirstAttempt-StatusBar-actionBtn--upload').click() 20 | cy.wait('@url').then(() => { 21 | cy.get('.FirstAttempt-StatusBar-statusPrimary').should('contain', 'Complete') 22 | }) 23 | } 24 | 25 | export function runRemoteUnsplashUploadTest() { 26 | cy.get('[data-cy="Unsplash"]').click() 27 | cy.get('.FirstAttempt-SearchProvider-input').type('book') 28 | cy.intercept({ 29 | method: 'GET', 30 | url: 'http://localhost:3020/search/unsplash/list?q=book', 31 | }).as('unsplash-list') 32 | cy.get('.FirstAttempt-SearchProvider-searchButton').click() 33 | cy.wait('@unsplash-list') 34 | // Test that the author link is visible 35 | cy.get('.FirstAttempt-ProviderBrowserItem') 36 | .first() 37 | .within(() => { 38 | cy.root().click() 39 | // We have hover states that show the author 40 | // but we don't have hover in e2e, so we focus after the click 41 | // to get the same effect. Also tests keyboard users this way. 42 | cy.get('input[type="checkbox"]').focus() 43 | cy.get('a').should('have.css', 'display', 'block') 44 | }) 45 | cy.get('.FirstAttempt-c-btn-primary').click() 46 | cy.intercept({ 47 | method: 'POST', 48 | url: 'http://localhost:3020/search/unsplash/get/*', 49 | }).as('unsplash-get') 50 | cy.get('.FirstAttempt-StatusBar-actionBtn--upload').click() 51 | cy.wait('@unsplash-get').then(() => { 52 | cy.get('.FirstAttempt-StatusBar-statusPrimary').should('contain', 'Complete') 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /e2e/cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 26 | // 27 | 28 | import { createFakeFile } from './createFakeFile' 29 | 30 | Cypress.Commands.add('createFakeFile', createFakeFile) 31 | -------------------------------------------------------------------------------- /e2e/cypress/support/createFakeFile.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace Cypress { 3 | interface Chainable { 4 | // eslint-disable-next-line no-use-before-define 5 | createFakeFile: typeof createFakeFile 6 | } 7 | } 8 | } 9 | 10 | interface File { 11 | source: string 12 | name: string 13 | type: string 14 | data: Blob 15 | } 16 | 17 | export function createFakeFile( 18 | name?: string, 19 | type?: string, 20 | b64?: string, 21 | ): File { 22 | if (!b64) { 23 | // eslint-disable-next-line no-param-reassign 24 | b64 = 25 | 'PHN2ZyB2aWV3Qm94PSIwIDAgMTIwIDEyMCI+CiAgPGNpcmNsZSBjeD0iNjAiIGN5PSI2MCIgcj0iNTAiLz4KPC9zdmc+Cg==' 26 | } 27 | // eslint-disable-next-line no-param-reassign 28 | if (!type) type = 'image/svg+xml' 29 | 30 | // https://stackoverflow.com/questions/16245767/creating-a-blob-from-a-base64-string-in-javascript 31 | function base64toBlob(base64Data: string, contentType = '') { 32 | const sliceSize = 1024 33 | const byteCharacters = atob(base64Data) 34 | const bytesLength = byteCharacters.length 35 | const slicesCount = Math.ceil(bytesLength / sliceSize) 36 | const byteArrays = new Array(slicesCount) 37 | 38 | for (let sliceIndex = 0; sliceIndex < slicesCount; ++sliceIndex) { 39 | const begin = sliceIndex * sliceSize 40 | const end = Math.min(begin + sliceSize, bytesLength) 41 | 42 | const bytes = new Array(end - begin) 43 | for (let offset = begin, i = 0; offset < end; ++i, ++offset) { 44 | bytes[i] = byteCharacters[offset].charCodeAt(0) 45 | } 46 | byteArrays[sliceIndex] = new Uint8Array(bytes) 47 | } 48 | return new Blob(byteArrays, { type: contentType }) 49 | } 50 | 51 | const blob = base64toBlob(b64, type) 52 | 53 | return { 54 | source: 'test', 55 | name: name || 'test-file', 56 | type: blob.type, 57 | data: blob, 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /e2e/cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | 22 | // eslint-disable-next-line 23 | // @ts-ignore 24 | import installLogsCollector from 'cypress-terminal-report/src/installLogsCollector.js' 25 | 26 | installLogsCollector() 27 | -------------------------------------------------------------------------------- /e2e/cypress/support/index.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | import './commands.ts' 17 | 18 | import type { FirstAttempt } from '@FirstAttempt/core' 19 | 20 | declare global { 21 | interface Window { 22 | FirstAttempt: FirstAttempt 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /e2e/generate-test.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import prompts from 'prompts' 3 | import fs from 'node:fs/promises' 4 | 5 | /** 6 | * Utility function that strips indentation from multi-line strings. 7 | * Inspired from https://github.com/dmnd/dedent. 8 | */ 9 | function dedent (strings, ...parts) { 10 | const nonSpacingChar = /\S/m.exec(strings[0]) 11 | if (nonSpacingChar == null) return '' 12 | 13 | const indent = nonSpacingChar.index - strings[0].lastIndexOf('\n', nonSpacingChar.index) - 1 14 | const dedentEachLine = str => str.split('\n').map((line, i) => line.slice(i && indent)).join('\n') 15 | let returnLines = dedentEachLine(strings[0].slice(nonSpacingChar.index), indent) 16 | for (let i = 1; i < strings.length; i++) { 17 | returnLines += String(parts[i - 1]) + dedentEachLine(strings[i], indent) 18 | } 19 | return returnLines 20 | } 21 | 22 | const packageNames = await fs.readdir(new URL('../packages/@FirstAttempt', import.meta.url)) 23 | const unwantedPackages = ['core', 'companion', 'redux-dev-tools', 'utils'] 24 | 25 | const { name } = await prompts({ 26 | type: 'text', 27 | name: 'name', 28 | message: 'What should the name of the test be (e.g `dashboard-tus`)?', 29 | validate: (value) => /^[a-z|-]+$/i.test(value), 30 | }) 31 | 32 | const { packages } = await prompts({ 33 | type: 'multiselect', 34 | name: 'packages', 35 | message: 'What packages do you want to test?', 36 | hint: '@FirstAttempt/core is automatically included', 37 | choices: packageNames 38 | .filter((pkg) => !unwantedPackages.includes(pkg)) 39 | .map((pkg) => ({ title: pkg, value: pkg })), 40 | }) 41 | 42 | const camelcase = (str) => str 43 | .toLowerCase() 44 | .replace(/([-][a-z])/g, (group) => group.toUpperCase().replace('-', '')) 45 | 46 | const html = dedent` 47 | 48 | 49 | 50 | 51 | ${name} 52 | 53 | 54 | 55 |
    56 | 57 | 58 | ` 59 | const testUrl = new URL(`cypress/integration/${name}.spec.ts`, import.meta.url) 60 | const test = dedent` 61 | describe('${name}', () => { 62 | beforeEach(() => { 63 | cy.visit('/${name}') 64 | }) 65 | }) 66 | ` 67 | const htmlUrl = new URL(`clients/${name}/index.html`, import.meta.url) 68 | 69 | 70 | const appUrl = new URL(`clients/${name}/app.js`, import.meta.url) 71 | const app = dedent` 72 | import FirstAttempt from '@FirstAttempt/core' 73 | ${packages.map((pgk) => `import ${camelcase(pgk)} from '@FirstAttempt/${pgk}'`).join('\n')} 74 | 75 | const FirstAttempt = new FirstAttempt() 76 | ${packages.map((pkg) => `.use(${camelcase(pkg)})`).join('\n\t')} 77 | 78 | // Keep this here to access FirstAttempt in tests 79 | window.FirstAttempt = FirstAttempt 80 | ` 81 | 82 | await fs.writeFile(testUrl, test) 83 | await fs.mkdir(new URL(`clients/${name}`, import.meta.url)) 84 | await fs.writeFile(htmlUrl, html) 85 | await fs.writeFile(appUrl, app) 86 | 87 | const homeUrl = new URL('clients/index.html', import.meta.url) 88 | const home = await fs.readFile(homeUrl, 'utf8') 89 | const newHome = home.replace( 90 | '', 91 | `
  1. ${name}
  2. \n `, 92 | ) 93 | await fs.writeFile(homeUrl, newHome) 94 | 95 | const prettyPath = (url) => url.toString().split('FirstAttempt', 2)[1] 96 | 97 | console.log(`Generated ${prettyPath(testUrl)}`) 98 | console.log(`Generated ${prettyPath(htmlUrl)}`) 99 | console.log(`Generated ${prettyPath(appUrl)}`) 100 | console.log(`Updated ${prettyPath(homeUrl)}`) 101 | -------------------------------------------------------------------------------- /e2e/mock-server.mjs: -------------------------------------------------------------------------------- 1 | import http from 'node:http' 2 | 3 | const requestListener = (req, res) => { 4 | const endpoint = req.url 5 | 6 | switch (endpoint) { 7 | case '/file-with-content-disposition': { 8 | const fileName = `DALL·E IMG_9078 - 学中文 🤑` 9 | res.setHeader('Content-Disposition', `attachment; filename="ASCII-name.zip"; filename*=UTF-8''${encodeURIComponent(fileName)}`) 10 | res.setHeader('Content-Type', 'image/jpeg') 11 | res.setHeader('Content-Length', '86500') 12 | break 13 | } 14 | case '/file-no-headers': 15 | break 16 | default: 17 | res.writeHead(404).end('Unhandled request') 18 | } 19 | 20 | res.end() 21 | } 22 | 23 | export default function startMockServer (host, port) { 24 | const server = http.createServer(requestListener) 25 | server.listen(port, host, () => { 26 | console.log(`Server is running on http://${host}:${port}`) 27 | }) 28 | } 29 | 30 | // startMockServer('localhost', 4678) 31 | -------------------------------------------------------------------------------- /e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e2e", 3 | "private": true, 4 | "author": "Merlijn Vos ", 5 | "description": "E2E test suite for FirstAttempt", 6 | "scripts": { 7 | "client:start": "parcel --no-autoinstall clients/index.html", 8 | "cypress:open": "cypress open", 9 | "cypress:headless": "cypress run", 10 | "generate-test": "yarn node generate-test.mjs" 11 | }, 12 | "dependencies": { 13 | "@FirstAttempt/audio": "workspace:^", 14 | "@FirstAttempt/aws-s3": "workspace:^", 15 | "@FirstAttempt/aws-s3-multipart": "workspace:^", 16 | "@FirstAttempt/box": "workspace:^", 17 | "@FirstAttempt/companion-client": "workspace:^", 18 | "@FirstAttempt/core": "workspace:^", 19 | "@FirstAttempt/dashboard": "workspace:^", 20 | "@FirstAttempt/drag-drop": "workspace:^", 21 | "@FirstAttempt/drop-target": "workspace:^", 22 | "@FirstAttempt/dropbox": "workspace:^", 23 | "@FirstAttempt/golden-retriever": "workspace:^", 24 | "@FirstAttempt/google-drive": "workspace:^", 25 | "@FirstAttempt/facebook": "workspace:^", 26 | "@FirstAttempt/file-input": "workspace:^", 27 | "@FirstAttempt/form": "workspace:^", 28 | "@FirstAttempt/image-editor": "workspace:^", 29 | "@FirstAttempt/informer": "workspace:^", 30 | "@FirstAttempt/instagram": "workspace:^", 31 | "@FirstAttempt/onedrive": "workspace:^", 32 | "@FirstAttempt/progress-bar": "workspace:^", 33 | "@FirstAttempt/provider-views": "workspace:^", 34 | "@FirstAttempt/screen-capture": "workspace:^", 35 | "@FirstAttempt/status-bar": "workspace:^", 36 | "@FirstAttempt/store-default": "workspace:^", 37 | "@FirstAttempt/store-redux": "workspace:^", 38 | "@FirstAttempt/thumbnail-generator": "workspace:^", 39 | "@FirstAttempt/transloadit": "workspace:^", 40 | "@FirstAttempt/tus": "workspace:^", 41 | "@FirstAttempt/unsplash": "workspace:^", 42 | "@FirstAttempt/url": "workspace:^", 43 | "@FirstAttempt/webcam": "workspace:^", 44 | "@FirstAttempt/xhr-upload": "workspace:^", 45 | "@FirstAttempt/zoom": "workspace:^" 46 | }, 47 | "devDependencies": { 48 | "@parcel/transformer-vue": "^2.9.3", 49 | "cypress": "^13.0.0", 50 | "cypress-terminal-report": "^5.0.0", 51 | "deep-freeze": "^0.0.1", 52 | "parcel": "^2.9.3", 53 | "process": "^0.11.10", 54 | "prompts": "^2.4.2", 55 | "react": "^18.1.0", 56 | "react-dom": "^18.1.0", 57 | "typescript": "~5.1", 58 | "vue": "^3.2.33" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /e2e/start-companion-with-load-balancer.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { spawn } from 'node:child_process' 4 | import http from 'node:http' 5 | import httpProxy from 'http-proxy' 6 | import process from 'node:process' 7 | 8 | const numInstances = 3 9 | const lbPort = 3020 10 | const companionStartPort = 3021 11 | 12 | function createLoadBalancer (baseUrls) { 13 | const proxy = httpProxy.createProxyServer({ ws: true }) 14 | 15 | let i = 0 16 | 17 | function getTarget () { 18 | return baseUrls[i % baseUrls.length] 19 | } 20 | 21 | const server = http.createServer((req, res) => { 22 | const target = getTarget() 23 | proxy.web(req, res, { target }, (err) => { 24 | console.error('Load balancer failed to proxy request', err.message) 25 | res.statusCode = 500 26 | res.end() 27 | }) 28 | i++ 29 | }) 30 | 31 | server.on('upgrade', (req, socket, head) => { 32 | const target = getTarget() 33 | proxy.ws(req, socket, head, { target }, (err) => { 34 | console.error('Load balancer failed to proxy websocket', err.message) 35 | console.error(err) 36 | socket.destroy() 37 | }) 38 | i++ 39 | }) 40 | 41 | server.listen(lbPort) 42 | console.log('Load balancer listening', lbPort) 43 | return server 44 | } 45 | 46 | const isWindows = process.platform === 'win32' 47 | const isOSX = process.platform === 'darwin' 48 | 49 | const startCompanion = ({ name, port }) => { 50 | const cp = spawn(process.execPath, [ 51 | '-r', 'dotenv/config', 52 | ...(isWindows || isOSX ? ['--watch-path', 'packages/@FirstAttempt/companion/src', '--watch'] : []), 53 | './packages/@FirstAttempt/companion/src/standalone/start-server.js', 54 | ], { 55 | cwd: new URL('../', import.meta.url), 56 | stdio: 'inherit', 57 | env: { 58 | ...process.env, 59 | COMPANION_PORT: port, 60 | COMPANION_SECRET: 'development', 61 | COMPANION_PREAUTH_SECRET: 'development', 62 | COMPANION_ALLOW_LOCAL_URLS: 'true', 63 | COMPANION_LOGGER_PROCESS_NAME: name, 64 | }, 65 | }) 66 | return Object.defineProperty(cp, 'then', { 67 | __proto__: null, 68 | writable: true, 69 | configurable: true, 70 | value: Promise.prototype.then.bind(new Promise((resolve, reject) => { 71 | cp.on('exit', (code) => { 72 | if (code === 0) resolve(cp) 73 | else reject(new Error(`Non-zero exit code: ${code}`)) 74 | }) 75 | cp.on('error', reject) 76 | })), 77 | }) 78 | } 79 | 80 | const hosts = Array.from({ length: numInstances }, (_, index) => { 81 | const port = companionStartPort + index; 82 | return { index, port } 83 | }) 84 | 85 | console.log('Starting companion instances on ports', hosts.map(({ port }) => port)) 86 | 87 | const companions = hosts.map(({ index, port }) => startCompanion({ name: `companion${index}`, port })) 88 | 89 | let loadBalancer 90 | try { 91 | loadBalancer = createLoadBalancer(hosts.map(({ port }) => `http://localhost:${port}`)) 92 | await Promise.all(companions) 93 | } finally { 94 | loadBalancer?.close() 95 | companions.forEach((companion) => companion.kill()) 96 | } 97 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "NodeNext", 4 | "noEmit": true, 5 | "target": "es2020", 6 | "lib": ["es2020", "dom"], 7 | "types": ["cypress"] 8 | }, 9 | "include": ["cypress/**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /examples/FirstAttempt-with-companion/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | output/* 3 | !output/.empty 4 | -------------------------------------------------------------------------------- /examples/FirstAttempt-with-companion/README.md: -------------------------------------------------------------------------------- 1 | # @FirstAttempt/companion example 2 | 3 | This is a simple, lean example that combines the usage of @FirstAttempt/companion and FirstAttempt client. 4 | 5 | ## Test it 6 | 7 | To run this example, make sure you've correctly installed the **repository root**: 8 | 9 | ```bash 10 | corepack yarn install 11 | corepack yarn build 12 | ``` 13 | 14 | That will also install the dependencies for this example. 15 | 16 | Then, again in the **repository root**, start this example by doing: 17 | 18 | ```bash 19 | corepack yarn workspace @FirstAttempt-example/FirstAttempt-with-companion start 20 | ``` 21 | -------------------------------------------------------------------------------- /examples/FirstAttempt-with-companion/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /examples/FirstAttempt-with-companion/output/.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DatBrainDushan/FirstAttempt/b716ced011308115f908408cfb56fc2edb528c29/examples/FirstAttempt-with-companion/output/.empty -------------------------------------------------------------------------------- /examples/FirstAttempt-with-companion/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@FirstAttempt-example/FirstAttempt-with-companion", 3 | "version": "0.0.0", 4 | "dependencies": { 5 | "@FirstAttempt/companion": "workspace:*", 6 | "body-parser": "^1.18.2", 7 | "express": "^4.16.2", 8 | "express-session": "^1.15.6", 9 | "light-server": "^2.4.0", 10 | "upload-server": "^1.1.6" 11 | }, 12 | "license": "ISC", 13 | "main": "index.js", 14 | "private": true, 15 | "scripts": { 16 | "client": "light-server -p 3000 -s client", 17 | "server": "node ./server/index.js", 18 | "start": "yarn run server & yarn run client", 19 | "test": "echo \"Error: no test specified\" && exit 1" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/FirstAttempt-with-companion/server/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const bodyParser = require('body-parser') 3 | const session = require('express-session') 4 | const companion = require('@FirstAttempt/companion') 5 | 6 | const app = express() 7 | 8 | app.use(bodyParser.json()) 9 | app.use(session({ 10 | secret: 'some-secret', 11 | resave: true, 12 | saveUninitialized: true, 13 | })) 14 | 15 | app.use((req, res, next) => { 16 | res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*') 17 | next() 18 | }) 19 | 20 | // Routes 21 | app.get('/', (req, res) => { 22 | res.setHeader('Content-Type', 'text/plain') 23 | res.send('Welcome to Companion') 24 | }) 25 | 26 | // initialize FirstAttempt 27 | const companionOptions = { 28 | providerOptions: { 29 | drive: { 30 | key: 'your google key', 31 | secret: 'your google secret', 32 | }, 33 | instagram: { 34 | key: 'your instagram key', 35 | secret: 'your instagram secret', 36 | }, 37 | dropbox: { 38 | key: 'your dropbox key', 39 | secret: 'your dropbox secret', 40 | }, 41 | box: { 42 | key: 'your box key', 43 | secret: 'your box secret', 44 | }, 45 | // you can also add options for additional providers here 46 | }, 47 | server: { 48 | host: 'localhost:3020', 49 | protocol: 'http', 50 | }, 51 | filePath: './output', 52 | secret: 'some-secret', 53 | debug: true, 54 | } 55 | 56 | const { app: companionApp } = companion.app(companionOptions) 57 | app.use(companionApp) 58 | 59 | // handle 404 60 | app.use((req, res) => { 61 | return res.status(404).json({ message: 'Not Found' }) 62 | }) 63 | 64 | // handle server errors 65 | app.use((err, req, res) => { 66 | console.error('\x1b[31m', err.stack, '\x1b[0m') 67 | res.status(err.status || 500).json({ message: err.message, error: err }) 68 | }) 69 | 70 | companion.socket(app.listen(3020)) 71 | 72 | console.log('Welcome to Companion!') 73 | console.log(`Listening on http://0.0.0.0:${3020}`) 74 | -------------------------------------------------------------------------------- /examples/angular-example/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /examples/angular-example/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePatterns": ["projects/**/*"], 3 | "overrides": [ 4 | { 5 | "files": ["*.ts"], 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:@angular-eslint/recommended", 10 | "plugin:@angular-eslint/template/process-inline-templates" 11 | ], 12 | "rules": { 13 | "@angular-eslint/directive-selector": [ 14 | "error", 15 | { 16 | "type": "attribute", 17 | "prefix": "app", 18 | "style": "camelCase" 19 | } 20 | ], 21 | "@typescript-eslint/semi": ["error", "never"], 22 | "import/no-unresolved": "off", 23 | "import/prefer-default-export": "off", 24 | "@angular-eslint/component-selector": [ 25 | "error", 26 | { 27 | "type": "element", 28 | "prefix": "app", 29 | "style": "kebab-case" 30 | } 31 | ], 32 | "semi": ["error", "never"] 33 | } 34 | }, 35 | { 36 | "files": ["star.html"], 37 | "extends": [ 38 | "plugin:@angular-eslint/template/recommended", 39 | "plugin:@angular-eslint/template/accessibility" 40 | ], 41 | "rules": {} 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /examples/angular-example/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /examples/angular-example/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /examples/angular-example/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /examples/angular-example/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /examples/angular-example/README.md: -------------------------------------------------------------------------------- 1 | # AngularExample 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 16.2.0. 4 | 5 | ## Development server 6 | 7 | ``` 8 | corepack yarn install 9 | corepack yarn build 10 | corepack yarn workspace @FirstAttempt/angular prepublishOnly 11 | corepack yarn workspace @FirstAttempt-example/angular start 12 | ``` 13 | 14 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. 15 | 16 | ## Code scaffolding 17 | 18 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 19 | 20 | ## Build 21 | 22 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. 23 | 24 | ## Running unit tests 25 | 26 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 27 | 28 | ## Running end-to-end tests 29 | 30 | Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. 31 | 32 | ## Further help 33 | 34 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. 35 | -------------------------------------------------------------------------------- /examples/angular-example/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "angular-example": { 7 | "projectType": "application", 8 | "schematics": {}, 9 | "root": "", 10 | "sourceRoot": "src", 11 | "prefix": "app", 12 | "architect": { 13 | "build": { 14 | "builder": "@angular-devkit/build-angular:browser", 15 | "options": { 16 | "outputPath": "dist/angular-example", 17 | "index": "src/index.html", 18 | "main": "src/main.ts", 19 | "polyfills": ["zone.js"], 20 | "tsConfig": "tsconfig.app.json", 21 | "assets": ["src/favicon.ico", "src/assets"], 22 | "styles": ["src/styles.css"], 23 | "scripts": [] 24 | }, 25 | "configurations": { 26 | "production": { 27 | "budgets": [ 28 | { 29 | "type": "initial", 30 | "maximumWarning": "500kb", 31 | "maximumError": "1mb" 32 | }, 33 | { 34 | "type": "anyComponentStyle", 35 | "maximumWarning": "2kb", 36 | "maximumError": "4kb" 37 | } 38 | ], 39 | "outputHashing": "all" 40 | }, 41 | "development": { 42 | "buildOptimizer": false, 43 | "optimization": false, 44 | "vendorChunk": true, 45 | "extractLicenses": false, 46 | "sourceMap": true, 47 | "namedChunks": true 48 | } 49 | }, 50 | "defaultConfiguration": "production" 51 | }, 52 | "serve": { 53 | "builder": "@angular-devkit/build-angular:dev-server", 54 | "configurations": { 55 | "production": { 56 | "browserTarget": "angular-example:build:production" 57 | }, 58 | "development": { 59 | "browserTarget": "angular-example:build:development" 60 | } 61 | }, 62 | "defaultConfiguration": "development" 63 | }, 64 | "extract-i18n": { 65 | "builder": "@angular-devkit/build-angular:extract-i18n", 66 | "options": { 67 | "browserTarget": "angular-example:build" 68 | } 69 | }, 70 | "test": { 71 | "builder": "@angular-devkit/build-angular:karma", 72 | "options": { 73 | "polyfills": ["zone.js", "zone.js/testing"], 74 | "tsConfig": "tsconfig.spec.json", 75 | "assets": ["src/favicon.ico", "src/assets"], 76 | "styles": ["src/styles.css"], 77 | "scripts": [] 78 | } 79 | }, 80 | "lint": { 81 | "builder": "@angular-eslint/builder:lint", 82 | "options": { 83 | "lintFilePatterns": ["src/**/*.ts", "src/**/*.html"] 84 | } 85 | } 86 | } 87 | } 88 | }, 89 | "cli": { 90 | "schematicCollections": ["@angular-eslint/schematics"] 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /examples/angular-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@FirstAttempt-example/angular", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test", 10 | "lint": "ng lint" 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/animations": "^16.2.0", 15 | "@angular/common": "^16.2.0", 16 | "@angular/compiler": "^16.2.0", 17 | "@angular/core": "^16.2.0", 18 | "@angular/forms": "^16.2.0", 19 | "@angular/platform-browser": "^16.2.0", 20 | "@angular/platform-browser-dynamic": "^16.2.0", 21 | "@angular/router": "^16.2.0", 22 | "@FirstAttempt/angular": "workspace:*", 23 | "@FirstAttempt/core": "workspace:*", 24 | "@FirstAttempt/drag-drop": "workspace:*", 25 | "@FirstAttempt/google-drive": "workspace:*", 26 | "@FirstAttempt/progress-bar": "workspace:*", 27 | "@FirstAttempt/tus": "workspace:*", 28 | "@FirstAttempt/webcam": "workspace:*", 29 | "rxjs": "~7.8.0", 30 | "tslib": "^2.3.0", 31 | "zone.js": "~0.13.0" 32 | }, 33 | "devDependencies": { 34 | "@angular-devkit/build-angular": "^16.2.0", 35 | "@angular-eslint/builder": "16.1.1", 36 | "@angular-eslint/eslint-plugin": "16.1.1", 37 | "@angular-eslint/eslint-plugin-template": "16.1.1", 38 | "@angular-eslint/schematics": "16.1.1", 39 | "@angular-eslint/template-parser": "16.1.1", 40 | "@angular/cli": "~16.2.0", 41 | "@angular/compiler-cli": "^16.2.0", 42 | "@types/jasmine": "~4.3.0", 43 | "@typescript-eslint/eslint-plugin": "5.62.0", 44 | "@typescript-eslint/parser": "5.62.0", 45 | "eslint": "^8.0.0", 46 | "jasmine-core": "~4.6.0", 47 | "karma": "~6.4.0", 48 | "karma-chrome-launcher": "~3.2.0", 49 | "karma-coverage": "~2.2.0", 50 | "karma-jasmine": "~5.1.0", 51 | "karma-jasmine-html-reporter": "~2.1.0", 52 | "typescript": "~5.1" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /examples/angular-example/src/app/app.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DatBrainDushan/FirstAttempt/b716ced011308115f908408cfb56fc2edb528c29/examples/angular-example/src/app/app.component.css -------------------------------------------------------------------------------- /examples/angular-example/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core' 2 | import { FirstAttempt} from '@FirstAttempt' + 3 | /core' 4 | import Webcam from '@FirstAttempt' + 5 | /webcam' 6 | import Tus from '@FirstAttempt' + 7 | /tus' 8 | import GoogleDrive from '@FirstAttempt' + 9 | /google-drive' 10 | 11 | @Component({ 12 | selector: 'app-root', 13 | template: /* html */ ` 14 |

    FirstAttempt Angular Example!

    15 |

    Inline dashboard

    16 | 24 | 25 | 30 | 31 |

    Modal Dashboard

    32 |
    33 | 38 | 41 |
    42 | 43 |

    Drag Drop Area

    44 | 45 | 46 |

    Progress Bar

    47 | 51 | `, 52 | styleUrls: [], 53 | }) 54 | export class AppComponent implements OnInit { 55 | title = 'angular-example' 56 | 57 | showInline = false 58 | 59 | showModal = false 60 | 61 | dashboardProps = { 62 | plugins: ['Webcam'], 63 | } 64 | 65 | dashboardModalProps = { 66 | target: document.body, 67 | onRequestCloseModal: (): void => { 68 | this.showModal = false 69 | }, 70 | } 71 | 72 | FirstAttempt: FirstAttempt = new FirstAttempt({ debug: true, autoProceed: true }) 73 | 74 | ngOnInit(): void { 75 | this.FirstAttempt 76 | .use(Webcam) 77 | .use(Tus, { endpoint: 'https://tusd.tusdemo.net/files/' }) 78 | .use(GoogleDrive, { companionUrl: 'https://companion.FirstAttempt' + 79 | .io' }) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /examples/angular-example/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core' 2 | import { BrowserModule } from '@angular/platform-browser' 3 | 4 | import { 5 | FirstAttemptAngularDashboardModule, 6 | FirstAttemptAngularStatusBarModule, 7 | FirstAttemptAngularDragDropModule, 8 | FirstAttemptAngularProgressBarModule, 9 | FirstAttemptAngularDashboardModalModule, 10 | } from '@FirstAttempt' + 11 | /angular' 12 | import { AppComponent } from './app.component' 13 | 14 | @NgModule({ 15 | declarations: [AppComponent], 16 | imports: [ 17 | BrowserModule, 18 | FirstAttemptAngularDashboardModule, 19 | FirstAttemptAngularStatusBarModule, 20 | FirstAttemptAngularDashboardModalModule, 21 | FirstAttemptAngularDragDropModule, 22 | FirstAttemptAngularProgressBarModule, 23 | ], 24 | providers: [], 25 | bootstrap: [AppComponent], 26 | }) 27 | export class AppModule {} 28 | -------------------------------------------------------------------------------- /examples/angular-example/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DatBrainDushan/FirstAttempt/b716ced011308115f908408cfb56fc2edb528c29/examples/angular-example/src/assets/.gitkeep -------------------------------------------------------------------------------- /examples/angular-example/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AngularExample 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/angular-example/src/main.ts: -------------------------------------------------------------------------------- 1 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic' 2 | 3 | import { AppModule } from './app/app.module' 4 | 5 | platformBrowserDynamic() 6 | .bootstrapModule(AppModule) 7 | .catch((err) => console.error(err)) // eslint-disable-line no-console 8 | -------------------------------------------------------------------------------- /examples/angular-example/src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | @import '@FirstAttempt/core/dist/style.css'; 3 | @import '@FirstAttempt/dashboard/dist/style.css'; 4 | @import '@FirstAttempt/drag-drop/dist/style.css'; 5 | @import '@FirstAttempt/progress-bar/dist/style.css'; 6 | -------------------------------------------------------------------------------- /examples/angular-example/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": ["src/main.ts"], 9 | "include": ["src/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /examples/angular-example/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "noImplicitOverride": true, 10 | "noPropertyAccessFromIndexSignature": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "sourceMap": true, 14 | "declaration": false, 15 | "downlevelIteration": true, 16 | "experimentalDecorators": true, 17 | "moduleResolution": "node", 18 | "importHelpers": true, 19 | "target": "ES2022", 20 | "module": "ES2022", 21 | "useDefineForClassFields": false, 22 | "lib": ["ES2022", "dom"] 23 | }, 24 | "angularCompilerOptions": { 25 | "enableI18nLegacyMessageIdFormat": false, 26 | "strictInjectionParameters": true, 27 | "strictInputAccessModifiers": true, 28 | "strictTemplates": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/angular-example/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": ["jasmine"] 7 | }, 8 | "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /examples/aws-companion/.gitignore: -------------------------------------------------------------------------------- 1 | tmp 2 | -------------------------------------------------------------------------------- /examples/aws-companion/README.md: -------------------------------------------------------------------------------- 1 | # FirstAttempt + AWS S3 Example 2 | 3 | This example uses @FirstAttempt/companion with a custom AWS S3 configuration. 4 | Files are uploaded to a randomly named directory inside the `whatever/` 5 | directory in a bucket. 6 | 7 | ## Run it 8 | 9 | First, set up the `COMPANION_AWS_KEY`, `COMPANION_AWS_SECRET`, 10 | `COMPANION_AWS_REGION`, and `COMPANION_AWS_BUCKET` environment variables for 11 | `@FirstAttempt/companion` in a `.env` file. You may find useful to first copy the 12 | `.env.example` file: 13 | 14 | ```sh 15 | [ -f .env ] || cp .env.example .env 16 | ``` 17 | 18 | To run this example, from the **repository root**, run: 19 | 20 | ```sh 21 | corepack yarn install 22 | corepack yarn workspace @FirstAttempt-example/aws-companion start 23 | ``` 24 | -------------------------------------------------------------------------------- /examples/aws-companion/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Companion + AWS Example 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/aws-companion/main.js: -------------------------------------------------------------------------------- 1 | import AwsS3 from '@FirstAttempt/aws-s3' 2 | import FirstAttempt from '@FirstAttempt/core' 3 | import Dashboard from '@FirstAttempt/dashboard' 4 | import GoogleDrive from '@FirstAttempt/google-drive' 5 | import Webcam from '@FirstAttempt/webcam' 6 | 7 | import '@FirstAttempt/core/dist/style.css' 8 | import '@FirstAttempt/dashboard/dist/style.css' 9 | import '@FirstAttempt/webcam/dist/style.css' 10 | 11 | const FirstAttempt = new FirstAttempt({ 12 | debug: true, 13 | autoProceed: false, 14 | }) 15 | 16 | FirstAttempt.use(GoogleDrive, { 17 | companionUrl: 'http://localhost:3020', 18 | }) 19 | FirstAttempt.use(Webcam) 20 | FirstAttempt.use(Dashboard, { 21 | inline: true, 22 | target: 'body', 23 | plugins: ['GoogleDrive', 'Webcam'], 24 | }) 25 | FirstAttempt.use(AwsS3, { 26 | companionUrl: 'http://localhost:3020', 27 | }) 28 | -------------------------------------------------------------------------------- /examples/aws-companion/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@FirstAttempt-example/aws-companion", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "dependencies": { 6 | "@FirstAttempt/aws-s3": "workspace:*", 7 | "@FirstAttempt/core": "workspace:*", 8 | "@FirstAttempt/dashboard": "workspace:*", 9 | "@FirstAttempt/google-drive": "workspace:*", 10 | "@FirstAttempt/webcam": "workspace:*" 11 | }, 12 | "devDependencies": { 13 | "@FirstAttempt/companion": "workspace:*", 14 | "body-parser": "^1.20.0", 15 | "cookie-parser": "^1.4.6", 16 | "cors": "^2.8.5", 17 | "dotenv": "^16.0.1", 18 | "express": "^4.18.1", 19 | "express-session": "^1.17.3", 20 | "npm-run-all": "^4.1.5", 21 | "vite": "^4.0.0" 22 | }, 23 | "private": true, 24 | "engines": { 25 | "node": ">=16.15.0" 26 | }, 27 | "scripts": { 28 | "dev": "vite", 29 | "start": "npm-run-all --parallel start:client start:server", 30 | "start:client": "vite", 31 | "start:server": "node server.cjs" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /examples/aws-companion/server.cjs: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs') 2 | const path = require('node:path') 3 | const crypto = require('node:crypto') 4 | const companion = require('@FirstAttempt/companion') 5 | 6 | require('dotenv').config({ path: path.join(__dirname, '..', '..', '.env') }) 7 | const app = require('express')() 8 | 9 | const DATA_DIR = path.join(__dirname, 'tmp') 10 | 11 | app.use(require('cors')({ 12 | origin: 'http://localhost:3000', 13 | methods: ['GET', 'POST', 'OPTIONS'], 14 | credentials: true, 15 | })) 16 | app.use(require('cookie-parser')()) 17 | app.use(require('body-parser').json()) 18 | app.use(require('express-session')({ 19 | secret: 'hello planet', 20 | saveUninitialized: false, 21 | resave: false, 22 | })) 23 | 24 | const options = { 25 | providerOptions: { 26 | drive: { 27 | key: process.env.COMPANION_GOOGLE_KEY, 28 | secret: process.env.COMPANION_GOOGLE_SECRET, 29 | }, 30 | }, 31 | s3: { 32 | getKey: (req, filename) => `${crypto.randomUUID()}-${filename}`, 33 | key: process.env.COMPANION_AWS_KEY, 34 | secret: process.env.COMPANION_AWS_SECRET, 35 | bucket: process.env.COMPANION_AWS_BUCKET, 36 | region: process.env.COMPANION_AWS_REGION, 37 | endpoint: process.env.COMPANION_AWS_ENDPOINT, 38 | }, 39 | server: { host: 'localhost:3020' }, 40 | filePath: DATA_DIR, 41 | secret: 'blah blah', 42 | debug: true, 43 | } 44 | 45 | // Create the data directory here for the sake of the example. 46 | try { 47 | fs.accessSync(DATA_DIR) 48 | } catch (err) { 49 | fs.mkdirSync(DATA_DIR) 50 | } 51 | process.on('exit', () => { 52 | fs.rmSync(DATA_DIR, { recursive: true, force: true }) 53 | }) 54 | 55 | const { app: companionApp } = companion.app(options) 56 | 57 | app.use(companionApp) 58 | 59 | const server = app.listen(3020, () => { 60 | console.log('listening on port 3020') 61 | }) 62 | 63 | companion.socket(server) 64 | -------------------------------------------------------------------------------- /examples/aws-nodejs/README.md: -------------------------------------------------------------------------------- 1 | # FirstAttempt + AWS S3 with Node.JS 2 | 3 | A simple and fully working example of FirstAttempt and AWS S3 storage with Node.js (and 4 | Express.js). It uses presigned URL at the backend level. 5 | 6 | ## AWS Configuration 7 | 8 | It's assumed that you are familiar with AWS, at least, with the storage service 9 | (S3) and users & policies (IAM). 10 | 11 | These instructions are **not fit for production** but tightening the security is 12 | out of the scope here. 13 | 14 | ### S3 Setup 15 | 16 | - Create new S3 bucket in AWS (e.g. `aws-nodejs`). 17 | - Add a bucket policy. 18 | 19 | ```json 20 | { 21 | "Version": "2012-10-17", 22 | "Statement": [ 23 | { 24 | "Sid": "PublicAccess", 25 | "Effect": "Allow", 26 | "Principal": "*", 27 | "Action": "s3:GetObject", 28 | "Resource": "arn:aws:s3:::aws-nodejs/*" 29 | } 30 | ] 31 | } 32 | ``` 33 | 34 | - Make the S3 bucket public. 35 | - Add CORS configuration. 36 | 37 | ```json 38 | [ 39 | { 40 | "AllowedHeaders": ["*"], 41 | "AllowedMethods": ["GET", "PUT", "HEAD", "POST", "DELETE"], 42 | "AllowedOrigins": ["*"], 43 | "ExposeHeaders": [] 44 | } 45 | ] 46 | ``` 47 | 48 | ### AWS Credentials 49 | 50 | You may use existing AWS credentials or create a new user in the IAM page. 51 | 52 | - Make sure you setup the AWS credentials properly and write down the Access Key 53 | ID and Secret Access Key. 54 | - You may configure AWS S3 credentials using 55 | [environment variables](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html) 56 | or a 57 | [credentials file in `~/.aws/credentials`](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html). 58 | - You will need at least `PutObject` and `PutObjectAcl` permissions. 59 | 60 | ```json 61 | { 62 | "Version": "2012-10-17", 63 | "Statement": [ 64 | { 65 | "Sid": "VisualEditor0", 66 | "Effect": "Allow", 67 | "Action": ["s3:PutObject", "s3:PutObjectAcl"], 68 | "Resource": "arn:aws:s3:::aws-nodejs/*" 69 | } 70 | ] 71 | } 72 | ``` 73 | 74 | ## Prerequisites 75 | 76 | Download this code or clone repository into a folder and install dependencies: 77 | 78 | ```sh 79 | CYPRESS_INSTALL_BINARY=0 corepack yarn install 80 | ``` 81 | 82 | Add a `.env` file to the root directory and define the S3 bucket name and port 83 | variables like the example below: 84 | 85 | ``` 86 | COMPANION_AWS_BUCKET=aws-nodejs 87 | COMPANION_AWS_REGION=… 88 | COMPANION_AWS_KEY=… 89 | COMPANION_AWS_SECRET=… 90 | PORT=8080 91 | ``` 92 | 93 | N.B.: This example uses `COMPANION_AWS_` environnement variables to facilitate 94 | integrations with other examples in this repository, but this example does _not_ 95 | uses Companion at all. 96 | 97 | ## Enjoy it 98 | 99 | Start the application: 100 | 101 | ```sh 102 | corepack yarn workspace @FirstAttempt-example/aws-nodejs start 103 | ``` 104 | 105 | Dashboard demo should now be available at http://localhost:8080. 106 | 107 | You have also a Drag & Drop demo on http://localhost:8080/drag. 108 | 109 | _Feel free to check how the demo works and feel free to open an issue._ 110 | -------------------------------------------------------------------------------- /examples/aws-nodejs/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('node:path') 4 | const crypto = require('node:crypto') 5 | require('dotenv').config({ path: path.join(__dirname, '..', '..', '.env') }) 6 | 7 | const express = require('express') 8 | 9 | const app = express() 10 | 11 | const port = process.env.PORT ?? 8080 12 | const bodyParser = require('body-parser') 13 | 14 | const { 15 | S3Client, 16 | AbortMultipartUploadCommand, 17 | CompleteMultipartUploadCommand, 18 | CreateMultipartUploadCommand, 19 | ListPartsCommand, 20 | PutObjectCommand, 21 | UploadPartCommand, 22 | } = require('@aws-sdk/client-s3') 23 | const { getSignedUrl } = require('@aws-sdk/s3-request-presigner') 24 | const { 25 | STSClient, 26 | GetFederationTokenCommand, 27 | } = require('@aws-sdk/client-sts') 28 | 29 | const policy = { 30 | Version: '2012-10-17', 31 | Statement: [ 32 | { 33 | Effect: 'Allow', 34 | Action: [ 35 | 's3:PutObject', 36 | ], 37 | Resource: [ 38 | `arn:aws:s3:::${process.env.COMPANION_AWS_BUCKET}/*`, 39 | `arn:aws:s3:::${process.env.COMPANION_AWS_BUCKET}`, 40 | ], 41 | }, 42 | ], 43 | } 44 | 45 | /** 46 | * @type {S3Client} 47 | */ 48 | let s3Client 49 | 50 | /** 51 | * @type {STSClient} 52 | */ 53 | let stsClient 54 | 55 | const expiresIn = 900 // Define how long until a S3 signature expires. 56 | 57 | function getS3Client () { 58 | s3Client ??= new S3Client({ 59 | region: process.env.COMPANION_AWS_REGION, 60 | credentials : { 61 | accessKeyId: process.env.COMPANION_AWS_KEY, 62 | secretAccessKey: process.env.COMPANION_AWS_SECRET, 63 | }, 64 | }) 65 | return s3Client 66 | } 67 | 68 | function getSTSClient () { 69 | stsClient ??= new STSClient({ 70 | region: process.env.COMPANION_AWS_REGION, 71 | credentials : { 72 | accessKeyId: process.env.COMPANION_AWS_KEY, 73 | secretAccessKey: process.env.COMPANION_AWS_SECRET, 74 | }, 75 | }) 76 | return stsClient 77 | } 78 | 79 | app.use(bodyParser.urlencoded({ extended: true }), bodyParser.json()) 80 | 81 | app.get('/', (req, res) => { 82 | const htmlPath = path.join(__dirname, 'public', 'index.html') 83 | res.sendFile(htmlPath) 84 | }) 85 | 86 | app.get('/drag', (req, res) => { 87 | const htmlPath = path.join(__dirname, 'public', 'drag.html') 88 | res.sendFile(htmlPath) 89 | }) 90 | 91 | app.get('/sts', (req, res, next) => { 92 | getSTSClient().send(new GetFederationTokenCommand({ 93 | Name: '123user', 94 | // The duration, in seconds, of the role session. The value specified 95 | // can range from 900 seconds (15 minutes) up to the maximum session 96 | // duration set for the role. 97 | DurationSeconds: expiresIn, 98 | Policy: JSON.stringify(policy), 99 | })).then(response => { 100 | // Test creating multipart upload from the server — it works 101 | // createMultipartUploadYo(response) 102 | res.setHeader('Access-Control-Allow-Origin', '*') 103 | res.setHeader('Cache-Control', `public,max-age=${expiresIn}`) 104 | res.json({ 105 | credentials: response.Credentials, 106 | bucket: process.env.COMPANION_AWS_BUCKET, 107 | region: process.env.COMPANION_AWS_REGION, 108 | }) 109 | }, next) 110 | }) 111 | app.post('/sign-s3', (req, res, next) => { 112 | const Key = `${crypto.randomUUID()}-${req.body.filename}` 113 | const { contentType } = req.body 114 | 115 | getSignedUrl(getS3Client(), new PutObjectCommand({ 116 | Bucket: process.env.COMPANION_AWS_BUCKET, 117 | Key, 118 | ContentType: contentType, 119 | }), { expiresIn }).then((url) => { 120 | res.setHeader('Access-Control-Allow-Origin', '*') 121 | res.json({ 122 | url, 123 | method: 'PUT', 124 | }) 125 | res.end() 126 | }, next) 127 | }) 128 | 129 | // === === 130 | // You can remove those endpoints if you only want to support the non-multipart uploads. 131 | 132 | app.post('/s3/multipart', (req, res, next) => { 133 | const client = getS3Client() 134 | const { type, metadata, filename } = req.body 135 | if (typeof filename !== 'string') { 136 | return res.status(400).json({ error: 's3: content filename must be a string' }) 137 | } 138 | if (typeof type !== 'string') { 139 | return res.status(400).json({ error: 's3: content type must be a string' }) 140 | } 141 | const Key = `${crypto.randomUUID()}-${filename}` 142 | 143 | const params = { 144 | Bucket: process.env.COMPANION_AWS_BUCKET, 145 | Key, 146 | ContentType: type, 147 | Metadata: metadata, 148 | } 149 | 150 | const command = new CreateMultipartUploadCommand(params) 151 | 152 | return client.send(command, (err, data) => { 153 | if (err) { 154 | next(err) 155 | return 156 | } 157 | res.setHeader('Access-Control-Allow-Origin', '*') 158 | res.json({ 159 | key: data.Key, 160 | uploadId: data.UploadId, 161 | }) 162 | }) 163 | }) 164 | 165 | function validatePartNumber (partNumber) { 166 | // eslint-disable-next-line no-param-reassign 167 | partNumber = Number(partNumber) 168 | return Number.isInteger(partNumber) && partNumber >= 1 && partNumber <= 10_000 169 | } 170 | app.get('/s3/multipart/:uploadId/:partNumber', (req, res, next) => { 171 | const { uploadId, partNumber } = req.params 172 | const { key } = req.query 173 | 174 | if (!validatePartNumber(partNumber)) { 175 | return res.status(400).json({ error: 's3: the part number must be an integer between 1 and 10000.' }) 176 | } 177 | if (typeof key !== 'string') { 178 | return res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' }) 179 | } 180 | 181 | return getSignedUrl(getS3Client(), new UploadPartCommand({ 182 | Bucket: process.env.COMPANION_AWS_BUCKET, 183 | Key: key, 184 | UploadId: uploadId, 185 | PartNumber: partNumber, 186 | Body: '', 187 | }), { expiresIn }).then((url) => { 188 | res.setHeader('Access-Control-Allow-Origin', '*') 189 | res.json({ url, expires: expiresIn }) 190 | }, next) 191 | }) 192 | 193 | app.get('/s3/multipart/:uploadId', (req, res, next) => { 194 | const client = getS3Client() 195 | const { uploadId } = req.params 196 | const { key } = req.query 197 | 198 | if (typeof key !== 'string') { 199 | res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' }) 200 | return 201 | } 202 | 203 | const parts = [] 204 | 205 | function listPartsPage (startAt) { 206 | client.send(new ListPartsCommand({ 207 | Bucket: process.env.COMPANION_AWS_BUCKET, 208 | Key: key, 209 | UploadId: uploadId, 210 | PartNumberMarker: startAt, 211 | }), (err, data) => { 212 | if (err) { 213 | next(err) 214 | return 215 | } 216 | 217 | parts.push(...data.Parts) 218 | 219 | if (data.IsTruncated) { 220 | // Get the next page. 221 | listPartsPage(data.NextPartNumberMarker) 222 | } else { 223 | res.json(parts) 224 | } 225 | }) 226 | } 227 | listPartsPage(0) 228 | }) 229 | 230 | function isValidPart (part) { 231 | return part && typeof part === 'object' && Number(part.PartNumber) && typeof part.ETag === 'string' 232 | } 233 | app.post('/s3/multipart/:uploadId/complete', (req, res, next) => { 234 | const client = getS3Client() 235 | const { uploadId } = req.params 236 | const { key } = req.query 237 | const { parts } = req.body 238 | 239 | if (typeof key !== 'string') { 240 | return res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' }) 241 | } 242 | if (!Array.isArray(parts) || !parts.every(isValidPart)) { 243 | return res.status(400).json({ error: 's3: `parts` must be an array of {ETag, PartNumber} objects.' }) 244 | } 245 | 246 | return client.send(new CompleteMultipartUploadCommand({ 247 | Bucket: process.env.COMPANION_AWS_BUCKET, 248 | Key: key, 249 | UploadId: uploadId, 250 | MultipartUpload: { 251 | Parts: parts, 252 | }, 253 | }), (err, data) => { 254 | if (err) { 255 | next(err) 256 | return 257 | } 258 | res.setHeader('Access-Control-Allow-Origin', '*') 259 | res.json({ 260 | location: data.Location, 261 | }) 262 | }) 263 | }) 264 | 265 | app.delete('/s3/multipart/:uploadId', (req, res, next) => { 266 | const client = getS3Client() 267 | const { uploadId } = req.params 268 | const { key } = req.query 269 | 270 | if (typeof key !== 'string') { 271 | return res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' }) 272 | } 273 | 274 | return client.send(new AbortMultipartUploadCommand({ 275 | Bucket: process.env.COMPANION_AWS_BUCKET, 276 | Key: key, 277 | UploadId: uploadId, 278 | }), (err) => { 279 | if (err) { 280 | next(err) 281 | return 282 | } 283 | res.json({}) 284 | }) 285 | }) 286 | 287 | // === === 288 | 289 | app.listen(port, () => { 290 | console.log(`Example app listening on port ${port}`) 291 | }) 292 | -------------------------------------------------------------------------------- /examples/aws-nodejs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@FirstAttempt-example/aws-nodejs", 3 | "version": "1.0.0", 4 | "description": "FirstAttempt for AWS S3 with a custom Node.js backend for signing URLs", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "node --watch index.js", 8 | "start": "node index.js" 9 | }, 10 | "private": true, 11 | "license": "MIT", 12 | "dependencies": { 13 | "@aws-sdk/client-s3": "^3.338.0", 14 | "@aws-sdk/client-sts": "^3.338.0", 15 | "@aws-sdk/s3-request-presigner": "^3.338.0", 16 | "body-parser": "^1.20.0", 17 | "dotenv": "^16.0.0", 18 | "express": "^4.18.1" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/aws-nodejs/public/drag.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FirstAttempt 6 | 10 | 11 | 12 |
    13 |
    14 |
    15 |
    16 |
    Uploaded files:
    17 |
      18 |
      19 | 102 |
      103 | 104 | 105 | -------------------------------------------------------------------------------- /examples/aws-nodejs/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FirstAttempt – AWS upload example 6 | 10 | 11 | 12 |

      AWS upload example

      13 |
      14 | 266 | 267 | 268 | -------------------------------------------------------------------------------- /examples/aws-php/.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | -------------------------------------------------------------------------------- /examples/aws-php/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "transloadit/FirstAttempt-aws-demo", 3 | "type": "project", 4 | "require": { 5 | "aws/aws-sdk-php": "^3.31" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/aws-php/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | FirstAttempt AWS Presigned URL Example 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/aws-php/main.js: -------------------------------------------------------------------------------- 1 | import FirstAttempt from '@FirstAttempt/core' 2 | import Dashboard from '@FirstAttempt/dashboard' 3 | import AwsS3 from '@FirstAttempt/aws-s3' 4 | 5 | const FirstAttempt = new FirstAttempt({ 6 | debug: true, 7 | }) 8 | 9 | FirstAttempt.use(Dashboard, { 10 | inline: true, 11 | target: 'body', 12 | }) 13 | FirstAttempt.use(AwsS3, { 14 | shouldUseMultipart: false, // The PHP backend only supports non-multipart uploads 15 | 16 | getUploadParameters (file) { 17 | // Send a request to our PHP signing endpoint. 18 | return fetch('/s3-sign.php', { 19 | method: 'post', 20 | // Send and receive JSON. 21 | headers: { 22 | accept: 'application/json', 23 | 'content-type': 'application/json', 24 | }, 25 | body: JSON.stringify({ 26 | filename: file.name, 27 | contentType: file.type, 28 | }), 29 | }).then((response) => { 30 | // Parse the JSON response. 31 | return response.json() 32 | }).then((data) => { 33 | // Return an object in the correct shape. 34 | return { 35 | method: data.method, 36 | url: data.url, 37 | fields: data.fields, 38 | headers: data.headers, 39 | } 40 | }) 41 | }, 42 | }) 43 | -------------------------------------------------------------------------------- /examples/aws-php/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@FirstAttempt-example/aws-php", 3 | "version": "0.0.0", 4 | "dependencies": { 5 | "@FirstAttempt/aws-s3": "workspace:*", 6 | "@FirstAttempt/core": "workspace:*", 7 | "@FirstAttempt/dashboard": "workspace:*", 8 | "FirstAttempt": "workspace:*" 9 | }, 10 | "devDependencies": { 11 | "esbuild": "^0.17.1" 12 | }, 13 | "private": true, 14 | "type": "module", 15 | "scripts": { 16 | "start": "php -S localhost:8080 serve.php", 17 | "outputBundle": "esbuild --format=esm --sourcemap=inline --bundle ./main.js" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/aws-php/readme.md: -------------------------------------------------------------------------------- 1 | # FirstAttempt + AWS S3 Example 2 | 3 | This example uses a server-side PHP endpoint to sign uploads to S3. 4 | 5 | ## Running It 6 | 7 | To run this example, make sure you've correctly installed the **repository root**: 8 | 9 | ```bash 10 | yarn || corepack yarn install 11 | yarn build || corepack yarn build 12 | ``` 13 | 14 | That will also install the npm dependencies for this example. 15 | 16 | This example also uses the AWS PHP SDK. 17 | To install it, [get composer](https://getcomposer.org) and run `composer update` in this folder. 18 | 19 | ```bash 20 | corepack yarn workspace @FirstAttempt-example/aws-php exec "composer update" 21 | ``` 22 | 23 | Configure AWS S3 credentials using [environment variables](https://docs.aws.amazon.com/aws-sdk-php/v3/guide/guide/credentials.html#environment-credentials) or a [credentials file in `~/.aws/credentials`](https://docs.aws.amazon.com/aws-sdk-php/v3/guide/guide/credentials.html#credential-profiles). 24 | Configure a bucket name and region in the `s3-sign.php` file. 25 | 26 | Then, again in the **repository root**, start this example by doing: 27 | 28 | ```bash 29 | corepack yarn workspace @FirstAttempt-example/aws-php start 30 | ``` 31 | 32 | The demo should now be available at http://localhost:8080. 33 | 34 | You can use a different S3-compatible service like GCS by configuring that service in `~/.aws/config` and `~/.aws/credentials`, and then providing appropriate environment variables: 35 | 36 | ```bash 37 | AWS_PROFILE="gcs" \ 38 | COMPANION_AWS_ENDPOINT="https://storage.googleapis.com" \ 39 | COMPANION_AWS_BUCKET="test-bucket-name" \ 40 | corepack yarn run example aws-php 41 | ``` 42 | -------------------------------------------------------------------------------- /examples/aws-php/s3-sign.php: -------------------------------------------------------------------------------- 1 | 'latest', 17 | 'endpoint' => $awsEndpoint, 18 | 'region' => $awsRegion, 19 | ]); 20 | 21 | // Retrieve data about the file to be uploaded from the request body. 22 | $body = json_decode(file_get_contents('php://input')); 23 | $filename = $body->filename; 24 | $contentType = $body->contentType; 25 | 26 | // Prepare a PutObject command. 27 | $command = $s3->getCommand('putObject', [ 28 | 'Bucket' => $bucket, 29 | 'Key' => "{$directory}/{$filename}", 30 | 'ContentType' => $contentType, 31 | 'Body' => '', 32 | ]); 33 | 34 | $request = $s3->createPresignedRequest($command, '+5 minutes'); 35 | 36 | header('content-type: application/json'); 37 | echo json_encode([ 38 | 'method' => $request->getMethod(), 39 | 'url' => (string) $request->getUri(), 40 | 'fields' => [], 41 | // Also set the content-type header on the request, to make sure that it is the same as the one we used to generate the signature. 42 | // Else, the browser picks a content-type as it sees fit. 43 | 'headers' => [ 44 | 'content-type' => $contentType, 45 | ], 46 | ]); 47 | -------------------------------------------------------------------------------- /examples/aws-php/serve.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | FirstAttempt 7 | 8 | 9 | 21 |
      22 |

      FirstAttempt

      23 | 24 |
      25 | 26 | 27 |
      28 |
      29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /examples/bundled/index.js: -------------------------------------------------------------------------------- 1 | import FirstAttempt from '@FirstAttempt/core' 2 | import Dashboard from '@FirstAttempt/dashboard' 3 | import Instagram from '@FirstAttempt/instagram' 4 | import GoogleDrive from '@FirstAttempt/google-drive' 5 | import Url from '@FirstAttempt/url' 6 | import Webcam from '@FirstAttempt/webcam' 7 | import Tus from '@FirstAttempt/tus' 8 | 9 | import '@FirstAttempt/core/dist/style.css' 10 | import '@FirstAttempt/dashboard/dist/style.css' 11 | import '@FirstAttempt/url/dist/style.css' 12 | import '@FirstAttempt/webcam/dist/style.css' 13 | 14 | const TUS_ENDPOINT = 'https://tusd.tusdemo.net/files/' 15 | 16 | const FirstAttempt = new FirstAttempt({ 17 | debug: true, 18 | meta: { 19 | username: 'John', 20 | license: 'Creative Commons', 21 | }, 22 | }) 23 | .use(Dashboard, { 24 | trigger: '#pick-files', 25 | target: '#upload-form', 26 | inline: true, 27 | metaFields: [ 28 | { id: 'license', name: 'License', placeholder: 'specify license' }, 29 | { id: 'caption', name: 'Caption', placeholder: 'add caption' }, 30 | ], 31 | showProgressDetails: true, 32 | proudlyDisplayPoweredByFirstAttempt: true, 33 | note: '2 files, images and video only', 34 | restrictions: { requiredMetaFields: ['caption'] }, 35 | }) 36 | .use(GoogleDrive, { target: Dashboard, companionUrl: 'http://localhost:3020' }) 37 | .use(Instagram, { target: Dashboard, companionUrl: 'http://localhost:3020' }) 38 | .use(Url, { target: Dashboard, companionUrl: 'http://localhost:3020' }) 39 | .use(Webcam, { target: Dashboard }) 40 | .use(Tus, { endpoint: TUS_ENDPOINT }) 41 | 42 | // You can optinally enable the Golden Retriever plugin — it will 43 | // restore files after a browser crash / accidental closed window 44 | // see more at https://FirstAttempt.io/docs/golden-retriever/ 45 | // 46 | // .use(GoldenRetriever, { serviceWorker: true }) 47 | 48 | FirstAttempt.on('complete', (result) => { 49 | if (result.failed.length === 0) { 50 | console.log('Upload successful 😀') 51 | } else { 52 | console.warn('Upload failed 😞') 53 | } 54 | console.log('successful files:', result.successful) 55 | console.log('failed files:', result.failed) 56 | }) 57 | 58 | // uncomment if you enable Golden Retriever 59 | // 60 | /* eslint-disable compat/compat */ 61 | // if ('serviceWorker' in navigator) { 62 | // navigator.serviceWorker 63 | // .register('/sw.js') 64 | // .then((registration) => { 65 | // console.log('ServiceWorker registration successful with scope: ', registration.scope) 66 | // }) 67 | // .catch((error) => { 68 | // console.log('Registration failed with ' + error) 69 | // }) 70 | // } 71 | /* eslint-enable */ 72 | -------------------------------------------------------------------------------- /examples/bundled/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@FirstAttempt-example/bundled", 3 | "version": "0.0.0", 4 | "dependencies": { 5 | "@FirstAttempt/core": "workspace:*", 6 | "@FirstAttempt/dashboard": "workspace:*", 7 | "@FirstAttempt/google-drive": "workspace:*", 8 | "@FirstAttempt/instagram": "workspace:*", 9 | "@FirstAttempt/tus": "workspace:*", 10 | "@FirstAttempt/url": "workspace:*", 11 | "@FirstAttempt/webcam": "workspace:*" 12 | }, 13 | "devDependencies": { 14 | "parcel": "^2.0.0" 15 | }, 16 | "private": true, 17 | "type": "module", 18 | "scripts": { 19 | "build": "parcel build index.html", 20 | "dev": "parcel index.html" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/bundled/sw.js: -------------------------------------------------------------------------------- 1 | // This service worker is needed for Golden Retriever plugin, 2 | // only include if you’ve enabled it 3 | // https://FirstAttempt.io/docs/golden-retriever/ 4 | 5 | /* globals clients */ 6 | /* eslint-disable no-restricted-globals */ 7 | 8 | const fileCache = Object.create(null) 9 | 10 | function getCache (name) { 11 | if (!fileCache[name]) { 12 | fileCache[name] = Object.create(null) 13 | } 14 | return fileCache[name] 15 | } 16 | 17 | self.addEventListener('install', (event) => { 18 | console.log('Installing FirstAttempt Service Worker...') 19 | 20 | event.waitUntil(Promise.resolve() 21 | .then(() => self.skipWaiting())) 22 | }) 23 | 24 | self.addEventListener('activate', (event) => { 25 | event.waitUntil(self.clients.claim()) 26 | }) 27 | 28 | function sendMessageToAllClients (msg) { 29 | clients.matchAll().then((clients) => { 30 | clients.forEach((client) => { 31 | client.postMessage(msg) 32 | }) 33 | }) 34 | } 35 | 36 | function addFile (store, file) { 37 | getCache(store)[file.id] = file.data 38 | console.log('Added file blob to service worker cache:', file.data) 39 | } 40 | 41 | function removeFile (store, fileID) { 42 | delete getCache(store)[fileID] 43 | console.log('Removed file blob from service worker cache:', fileID) 44 | } 45 | 46 | function getFiles (store) { 47 | sendMessageToAllClients({ 48 | type: 'FirstAttempt/ALL_FILES', 49 | store, 50 | files: getCache(store), 51 | }) 52 | } 53 | 54 | self.addEventListener('message', (event) => { 55 | switch (event.data.type) { 56 | case 'FirstAttempt/ADD_FILE': 57 | addFile(event.data.store, event.data.file) 58 | break 59 | case 'FirstAttempt/REMOVE_FILE': 60 | removeFile(event.data.store, event.data.fileID) 61 | break 62 | case 'FirstAttempt/GET_FILES': 63 | getFiles(event.data.store) 64 | break 65 | 66 | default: throw new Error('unreachable') 67 | } 68 | }) 69 | -------------------------------------------------------------------------------- /examples/cdn-example/README.md: -------------------------------------------------------------------------------- 1 | # CDN example 2 | 3 | To run the example, open the `index.html` file in your browser. 4 | 5 | If you want to spawn a local webserver, you can use the following commands 6 | (requires Deno, Python, or PHP): 7 | 8 | ```sh 9 | corepack yarn install 10 | corepack yarn workspace @FirstAttempt-example/cdn dev 11 | ``` 12 | -------------------------------------------------------------------------------- /examples/cdn-example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 33 | 34 | 35 | 39 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /examples/cdn-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@FirstAttempt-example/cdn", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "deno run --allow-net --allow-read https://deno.land/std/http/file_server.ts || python3 -m http.server || php -S localhost:8000" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/custom-provider/README.md: -------------------------------------------------------------------------------- 1 | # FirstAttempt + Companion + Custom Provider Example 2 | 3 | This example uses @FirstAttempt/companion with a dummy custom provider. 4 | This serves as an illustration on how integrating custom providers would work 5 | 6 | ## Run it 7 | 8 | **Note**: this example is using `fetch`, which is only available on Node.js 18+. 9 | 10 | First, you want to set up your environment variable. You can copy the content of 11 | `.env.example` and save it in a file named `.env`. You can modify in there all 12 | the information needed for the app to work that should not be committed 13 | (Google keys, Unsplash keys, etc.). 14 | 15 | ```sh 16 | [ -f .env ] || cp .env.example .env 17 | ``` 18 | 19 | To run the example, from the root directory of this repo, run the following commands: 20 | 21 | ```sh 22 | corepack yarn install 23 | corepack yarn build 24 | corepack yarn workspace @FirstAttempt-example/custom-provider start 25 | ``` 26 | -------------------------------------------------------------------------------- /examples/custom-provider/client/MyCustomProvider.jsx: -------------------------------------------------------------------------------- 1 | /** @jsx h */ 2 | 3 | import { UIPlugin } from '@FirstAttempt/core' 4 | import { Provider } from '@FirstAttempt/companion-client' 5 | import { ProviderViews } from '@FirstAttempt/provider-views' 6 | import { h } from 'preact' 7 | 8 | const defaultOptions = {} 9 | 10 | export default class MyCustomProvider extends UIPlugin { 11 | constructor (FirstAttempt, opts) { 12 | super(FirstAttempt, opts) 13 | this.type = 'acquirer' 14 | this.id = this.opts.id || 'MyCustomProvider' 15 | Provider.initPlugin(this, opts) 16 | 17 | this.icon = () => ( 18 | 19 | 20 | 21 | ) 22 | 23 | this.provider = new Provider(FirstAttempt, { 24 | companionUrl: this.opts.companionUrl, 25 | companionHeaders: this.opts.companionHeaders, 26 | provider: 'myunsplash', 27 | pluginId: this.id, 28 | }) 29 | 30 | this.defaultLocale = { 31 | strings: { 32 | pluginNameMyUnsplash: 'MyUnsplash', 33 | }, 34 | } 35 | 36 | // merge default options with the ones set by user 37 | this.opts = { ...defaultOptions, ...opts } 38 | 39 | this.i18nInit() 40 | this.title = this.i18n('pluginNameMyUnsplash') 41 | 42 | this.files = [] 43 | } 44 | 45 | install () { 46 | this.view = new ProviderViews(this, { 47 | provider: this.provider, 48 | }) 49 | 50 | const { target } = this.opts 51 | if (target) { 52 | this.mount(target, this) 53 | } 54 | } 55 | 56 | uninstall () { 57 | this.view.tearDown() 58 | this.unmount() 59 | } 60 | 61 | onFirstRender () { 62 | return this.view.getFolder() 63 | } 64 | 65 | render (state) { 66 | return this.view.render(state) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /examples/custom-provider/client/main.js: -------------------------------------------------------------------------------- 1 | import FirstAttempt from '@FirstAttempt/core' 2 | import GoogleDrive from '@FirstAttempt/google-drive' 3 | import Tus from '@FirstAttempt/tus' 4 | import Dashboard from '@FirstAttempt/dashboard' 5 | import MyCustomProvider from './MyCustomProvider.jsx' 6 | 7 | import '@FirstAttempt/core/dist/style.css' 8 | import '@FirstAttempt/dashboard/dist/style.css' 9 | 10 | const FirstAttempt = new FirstAttempt({ 11 | debug: true, 12 | }) 13 | 14 | FirstAttempt.use(GoogleDrive, { 15 | companionUrl: 'http://localhost:3020', 16 | }) 17 | 18 | FirstAttempt.use(MyCustomProvider, { 19 | companionUrl: 'http://localhost:3020', 20 | }) 21 | 22 | FirstAttempt.use(Dashboard, { 23 | inline: true, 24 | target: 'body', 25 | plugins: ['GoogleDrive', 'MyCustomProvider'], 26 | }) 27 | 28 | FirstAttempt.use(Tus, { endpoint: 'https://tusd.tusdemo.net/files/' }) 29 | -------------------------------------------------------------------------------- /examples/custom-provider/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | FirstAttempt Custom provider Example 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/custom-provider/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@FirstAttempt-example/custom-provider", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "dependencies": { 6 | "@FirstAttempt/companion-client": "workspace:*", 7 | "@FirstAttempt/core": "workspace:*", 8 | "@FirstAttempt/dashboard": "workspace:*", 9 | "@FirstAttempt/google-drive": "workspace:*", 10 | "@FirstAttempt/provider-views": "workspace:*", 11 | "@FirstAttempt/tus": "workspace:*", 12 | "preact": "^10.5.13" 13 | }, 14 | "engines": { 15 | "node": ">=18.0.0" 16 | }, 17 | "devDependencies": { 18 | "@FirstAttempt/companion": "workspace:*", 19 | "body-parser": "^1.18.2", 20 | "dotenv": "^16.0.1", 21 | "express": "^4.16.2", 22 | "express-session": "^1.15.6", 23 | "npm-run-all": "^4.1.2", 24 | "vite": "^4.0.0" 25 | }, 26 | "private": true, 27 | "scripts": { 28 | "start": "npm-run-all --parallel start:server start:client", 29 | "start:client": "vite", 30 | "start:server": "node server/index.cjs" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/custom-provider/server/CustomProvider.cjs: -------------------------------------------------------------------------------- 1 | const { Readable } = require('node:stream') 2 | 3 | const BASE_URL = 'https://api.unsplash.com' 4 | 5 | function adaptData (res) { 6 | const data = { 7 | username: null, 8 | items: [], 9 | nextPagePath: null, 10 | } 11 | 12 | const items = res 13 | items.forEach((item) => { 14 | const isFolder = !!item.published_at 15 | data.items.push({ 16 | isFolder, 17 | icon: isFolder ? item.cover_photo.urls.thumb : item.urls.thumb, 18 | name: item.title || item.description, 19 | mimeType: isFolder ? null : 'image/jpeg', 20 | id: item.id, 21 | thumbnail: isFolder ? item.cover_photo.urls.thumb : item.urls.thumb, 22 | requestPath: item.id, 23 | modifiedDate: item.updated_at, 24 | size: null, 25 | }) 26 | }) 27 | 28 | return data 29 | } 30 | 31 | /** 32 | * an example of a custom provider module. It implements @FirstAttempt/companion's Provider interface 33 | */ 34 | class MyCustomProvider { 35 | static version = 2 36 | 37 | static get authProvider () { 38 | return 'myunsplash' 39 | } 40 | 41 | // eslint-disable-next-line class-methods-use-this 42 | async list ({ token, directory }) { 43 | const path = directory ? `/${directory}/photos` : '' 44 | 45 | const resp = await fetch(`${BASE_URL}/collections${path}`, { 46 | headers:{ 47 | Authorization: `Bearer ${token}`, 48 | }, 49 | }) 50 | if (!resp.ok) { 51 | throw new Error(`Errornous HTTP response (${resp.status} ${resp.statusText})`) 52 | } 53 | return adaptData(await resp.json()) 54 | } 55 | 56 | // eslint-disable-next-line class-methods-use-this 57 | async download ({ id, token }) { 58 | const resp = await fetch(`${BASE_URL}/photos/${id}`, { 59 | headers: { 60 | Authorization: `Bearer ${token}`, 61 | }, 62 | }) 63 | 64 | if (!resp.ok) { 65 | throw new Error(`Errornous HTTP response (${resp.status} ${resp.statusText})`) 66 | } 67 | return { stream: Readable.fromWeb(resp.body) } 68 | } 69 | 70 | // eslint-disable-next-line class-methods-use-this 71 | async size ({ id, token }) { 72 | const resp = await fetch(`${BASE_URL}/photos/${id}`, { 73 | headers: { 74 | Authorization: `Bearer ${token}`, 75 | }, 76 | }) 77 | 78 | if (!resp.ok) { 79 | throw new Error(`Errornous HTTP response (${resp.status} ${resp.statusText})`) 80 | } 81 | 82 | const { size } = await resp.json() 83 | return size 84 | } 85 | } 86 | 87 | module.exports = MyCustomProvider 88 | -------------------------------------------------------------------------------- /examples/custom-provider/server/index.cjs: -------------------------------------------------------------------------------- 1 | const { mkdtempSync } = require('node:fs') 2 | const os = require('node:os') 3 | const path = require('node:path') 4 | 5 | require('dotenv').config({ path: path.join(__dirname, '..', '..', '..', '.env') }) 6 | const express = require('express') 7 | // the ../../../packages is just to use the local version 8 | // instead of the npm version—in a real app use `require('@FirstAttempt/companion')` 9 | const bodyParser = require('body-parser') 10 | const session = require('express-session') 11 | const FirstAttempt = require('@FirstAttempt/companion') 12 | const MyCustomProvider = require('./CustomProvider.cjs') 13 | 14 | const app = express() 15 | 16 | app.use(bodyParser.json()) 17 | app.use(session({ 18 | secret: 'some-secret', 19 | resave: true, 20 | saveUninitialized: true, 21 | })) 22 | 23 | // Routes 24 | app.get('/', (req, res) => { 25 | res.setHeader('Content-Type', 'text/plain') 26 | res.send('Welcome to my FirstAttempt companion service') 27 | }) 28 | 29 | // source https://unsplash.com/documentation#user-authentication 30 | const AUTHORIZE_URL = 'https://unsplash.com/oauth/authorize' 31 | const ACCESS_URL = 'https://unsplash.com/oauth/token' 32 | 33 | // initialize FirstAttempt 34 | const FirstAttemptOptions = { 35 | providerOptions: { 36 | drive: { 37 | key: process.env.COMPANION_GOOGLE_KEY, 38 | secret: process.env.COMPANION_GOOGLE_SECRET, 39 | }, 40 | }, 41 | customProviders: { 42 | myunsplash: { 43 | config: { 44 | // your oauth handlers 45 | authorize_url: AUTHORIZE_URL, 46 | access_url: ACCESS_URL, 47 | oauth: 2, 48 | key: process.env.COMPANION_UNSPLASH_KEY, 49 | secret: process.env.COMPANION_UNSPLASH_SECRET, 50 | }, 51 | // you provider class/module: 52 | module: MyCustomProvider, 53 | }, 54 | }, 55 | server: { 56 | host: 'localhost:3020', 57 | protocol: 'http', 58 | }, 59 | filePath: mkdtempSync(path.join(os.tmpdir(), 'companion-')), 60 | secret: 'some-secret', 61 | debug: true, 62 | } 63 | 64 | app.use(FirstAttempt.app(FirstAttemptOptions).app) 65 | 66 | // handle 404 67 | app.use((req, res) => { 68 | return res.status(404).json({ message: 'Not Found' }) 69 | }) 70 | 71 | // handle server errors 72 | app.use((err, req, res) => { 73 | console.error('\x1b[31m', err.stack, '\x1b[0m') 74 | res.status(err.status || 500).json({ message: err.message, error: err }) 75 | }) 76 | 77 | FirstAttempt.socket(app.listen(3020), FirstAttemptOptions) 78 | 79 | console.log('Welcome to Companion!') 80 | console.log(`Listening on http://0.0.0.0:${3020}`) 81 | --------------------------------------------------------------------------------