mirror of
https://github.com/plankanban/planka.git
synced 2025-12-17 09:13:23 +03:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b700c307c3 | ||
|
|
9794919fd2 | ||
|
|
917dcf31cf | ||
|
|
850f6df0ac | ||
|
|
242d415142 | ||
|
|
950a070589 | ||
|
|
f6ea10df97 | ||
|
|
d9e8c24c3f | ||
|
|
f75b0237d3 | ||
|
|
38bc4cb0a0 | ||
|
|
cc95032e74 | ||
|
|
1d2193c381 | ||
|
|
70f40e26af | ||
|
|
814b5810ac | ||
|
|
f372113def | ||
|
|
14dff96434 | ||
|
|
7580afdb1c | ||
|
|
2458709031 | ||
|
|
e0d5d7f4ff | ||
|
|
bedd1f6e2a | ||
|
|
d9464dc3f9 | ||
|
|
87c1eeb5f8 | ||
|
|
98413d759a | ||
|
|
fa3b1d7b28 | ||
|
|
23e5f1a326 | ||
|
|
de44b520af | ||
|
|
e7d7425768 | ||
|
|
df7746f896 | ||
|
|
01a7e3a57d | ||
|
|
234bd1e8b3 |
@@ -47,10 +47,10 @@ jobs:
|
|||||||
- name: Dist create .zip file
|
- name: Dist create .zip file
|
||||||
run: |
|
run: |
|
||||||
mv dist/ planka/
|
mv dist/ planka/
|
||||||
zip -r planka-prebuild-${{ github.event.release.tag_name }}.zip planka
|
zip -r planka-prebuild.zip planka
|
||||||
|
|
||||||
- name: Dist upload assets
|
- name: Dist upload assets
|
||||||
run: |
|
run: |
|
||||||
gh release upload ${{ github.event.release.tag_name }} planka-prebuild-${{ github.event.release.tag_name }}.zip#planka-prebuild.zip
|
gh release upload ${{ github.event.release.tag_name }} planka-prebuild.zip
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
|||||||
73
.github/workflows/build-and-test.yml
vendored
Normal file
73
.github/workflows/build-and-test.yml
vendored
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
name: Build and test
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
setup:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: planka_db
|
||||||
|
POSTGRES_USER: user
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Setup PostgreSQL
|
||||||
|
uses: ikalnytskyi/action-setup-postgres@v5
|
||||||
|
with:
|
||||||
|
database: ${{ env.POSTGRES_DB }}
|
||||||
|
username: ${{ env.POSTGRES_USER }}
|
||||||
|
password: ${{ env.POSTGRES_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Cache Node.js modules
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: client/node_modules
|
||||||
|
key: ${{ runner.os }}-node-${{ hashFiles('client/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
npm install
|
||||||
|
cd client
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Setup server
|
||||||
|
env:
|
||||||
|
DEFAULT_ADMIN_EMAIL: demo@demo.demo
|
||||||
|
DEFAULT_ADMIN_PASSWORD: demo
|
||||||
|
DEFAULT_ADMIN_NAME: Demo Demo
|
||||||
|
DEFAULT_ADMIN_USERNAME: demo
|
||||||
|
run: |
|
||||||
|
client/tests/setup-symlinks.sh
|
||||||
|
cd server
|
||||||
|
cp .env.sample .env
|
||||||
|
sed -i "s|^DATABASE_URL=.*|DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost/${POSTGRES_DB}|" .env
|
||||||
|
npm run db:init
|
||||||
|
npm start --prod &
|
||||||
|
|
||||||
|
- name: Wait for development server
|
||||||
|
run: |
|
||||||
|
sudo apt-get install wait-for-it -y
|
||||||
|
wait-for-it -h localhost -p 1337 -t 10
|
||||||
|
|
||||||
|
- name: Run UI tests
|
||||||
|
run: |
|
||||||
|
cd client
|
||||||
|
npm install
|
||||||
|
npx playwright install chromium
|
||||||
|
npm run test:acceptance tests
|
||||||
33
.github/workflows/lint.yml
vendored
Normal file
33
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
setup:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Cache Node.js modules
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: client/node_modules
|
||||||
|
key: ${{ runner.os }}-node-${{ hashFiles('client/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Run linter
|
||||||
|
run: npm run lint
|
||||||
@@ -31,6 +31,8 @@ Here is the [mobile app repository](https://github.com/LouisHDev/planka_app) mai
|
|||||||
|
|
||||||
Alternatively, you can download the [Android APK](https://github.com/LouisHDev/planka_app/releases/latest/download/app-release.apk) directly.
|
Alternatively, you can download the [Android APK](https://github.com/LouisHDev/planka_app/releases/latest/download/app-release.apk) directly.
|
||||||
|
|
||||||
|
If you have an iOS device and would like to test the app, you can join [TestFlight](https://testflight.apple.com/join/Uwn41eY4) (limited to 200 participants).
|
||||||
|
|
||||||
## Contact
|
## Contact
|
||||||
|
|
||||||
- If you want to get a hosted version of Planka, you can contact us via email contact@planka.cloud
|
- If you want to get a hosted version of Planka, you can contact us via email contact@planka.cloud
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ type: application
|
|||||||
# This is the chart version. This version number should be incremented each time you make changes
|
# This is the chart version. This version number should be incremented each time you make changes
|
||||||
# to the chart and its templates, including the app version.
|
# to the chart and its templates, including the app version.
|
||||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||||
version: 0.2.11
|
version: 0.2.15
|
||||||
|
|
||||||
# This is the version number of the application being deployed. This version number should be
|
# This is the version number of the application being deployed. This version number should be
|
||||||
# incremented each time you make changes to the application. Versions are not expected to
|
# incremented each time you make changes to the application. Versions are not expected to
|
||||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||||
# It is recommended to use it with quotes.
|
# It is recommended to use it with quotes.
|
||||||
appVersion: "1.23.2"
|
appVersion: "1.24.0"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- alias: postgresql
|
- alias: postgresql
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ spec:
|
|||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: planka-postgresql-svcbind-custom-user
|
name: {{ include "planka.fullname" . }}-postgresql-svcbind-custom-user
|
||||||
key: uri
|
key: uri
|
||||||
{{- end }}
|
{{- end }}
|
||||||
- name: BASE_URL
|
- name: BASE_URL
|
||||||
|
|||||||
941
client/package-lock.json
generated
941
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,8 @@
|
|||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject",
|
||||||
"lint": "eslint --ext js,jsx src config-overrides.js",
|
"lint": "eslint --ext js,jsx src config-overrides.js",
|
||||||
"start": "react-app-rewired start",
|
"start": "react-app-rewired start",
|
||||||
"test": "react-app-rewired test"
|
"test": "react-app-rewired test",
|
||||||
|
"test:acceptance": "cucumber-js --require tests/acceptance/cucumber.conf.js --require tests/acceptance/stepDefinitions/**/*.js --format @cucumber/pretty-formatter"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
@@ -108,9 +109,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||||
|
"@cucumber/cucumber": "^7.3.1",
|
||||||
|
"@cucumber/pretty-formatter": "^1.0.1",
|
||||||
|
"@playwright/test": "^1.46.1",
|
||||||
"@testing-library/jest-dom": "^6.5.0",
|
"@testing-library/jest-dom": "^6.5.0",
|
||||||
"@testing-library/react": "^15.0.7",
|
"@testing-library/react": "^15.0.7",
|
||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/user-event": "^14.5.2",
|
||||||
|
"axios": "^1.6.2",
|
||||||
"babel-preset-airbnb": "^5.0.0",
|
"babel-preset-airbnb": "^5.0.0",
|
||||||
"chai": "^4.5.0",
|
"chai": "^4.5.0",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
@@ -119,6 +124,7 @@
|
|||||||
"eslint-plugin-jsx-a11y": "^6.10.0",
|
"eslint-plugin-jsx-a11y": "^6.10.0",
|
||||||
"eslint-plugin-react": "^7.36.1",
|
"eslint-plugin-react": "^7.36.1",
|
||||||
"eslint-plugin-react-hooks": "^4.6.2",
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
|
"playwright": "^1.46.1",
|
||||||
"react-test-renderer": "18.2.0"
|
"react-test-renderer": "18.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const Card = React.memo(
|
|||||||
id,
|
id,
|
||||||
index,
|
index,
|
||||||
name,
|
name,
|
||||||
|
description,
|
||||||
dueDate,
|
dueDate,
|
||||||
isDueDateCompleted,
|
isDueDateCompleted,
|
||||||
stopwatch,
|
stopwatch,
|
||||||
@@ -31,6 +32,7 @@ const Card = React.memo(
|
|||||||
listId,
|
listId,
|
||||||
projectId,
|
projectId,
|
||||||
isPersisted,
|
isPersisted,
|
||||||
|
attachmentsTotal,
|
||||||
notificationsTotal,
|
notificationsTotal,
|
||||||
users,
|
users,
|
||||||
labels,
|
labels,
|
||||||
@@ -106,7 +108,11 @@ const Card = React.memo(
|
|||||||
)}
|
)}
|
||||||
<div className={styles.name}>{name}</div>
|
<div className={styles.name}>{name}</div>
|
||||||
{tasks.length > 0 && <Tasks items={tasks} />}
|
{tasks.length > 0 && <Tasks items={tasks} />}
|
||||||
{(dueDate || stopwatch || notificationsTotal > 0) && (
|
{(description ||
|
||||||
|
dueDate ||
|
||||||
|
stopwatch ||
|
||||||
|
attachmentsTotal > 0 ||
|
||||||
|
notificationsTotal > 0) && (
|
||||||
<span className={styles.attachments}>
|
<span className={styles.attachments}>
|
||||||
{notificationsTotal > 0 && (
|
{notificationsTotal > 0 && (
|
||||||
<span
|
<span
|
||||||
@@ -135,6 +141,21 @@ const Card = React.memo(
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{description && (
|
||||||
|
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
|
||||||
|
<span className={styles.attachmentContent}>
|
||||||
|
<Icon name="align left" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{attachmentsTotal > 0 && (
|
||||||
|
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
|
||||||
|
<span className={styles.attachmentContent}>
|
||||||
|
<Icon name="attach" />
|
||||||
|
{attachmentsTotal}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{users.length > 0 && (
|
{users.length > 0 && (
|
||||||
@@ -221,6 +242,7 @@ Card.propTypes = {
|
|||||||
id: PropTypes.string.isRequired,
|
id: PropTypes.string.isRequired,
|
||||||
index: PropTypes.number.isRequired,
|
index: PropTypes.number.isRequired,
|
||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
|
description: PropTypes.string,
|
||||||
dueDate: PropTypes.instanceOf(Date),
|
dueDate: PropTypes.instanceOf(Date),
|
||||||
isDueDateCompleted: PropTypes.bool,
|
isDueDateCompleted: PropTypes.bool,
|
||||||
stopwatch: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
stopwatch: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||||
@@ -229,6 +251,7 @@ Card.propTypes = {
|
|||||||
listId: PropTypes.string.isRequired,
|
listId: PropTypes.string.isRequired,
|
||||||
projectId: PropTypes.string.isRequired,
|
projectId: PropTypes.string.isRequired,
|
||||||
isPersisted: PropTypes.bool.isRequired,
|
isPersisted: PropTypes.bool.isRequired,
|
||||||
|
attachmentsTotal: PropTypes.number.isRequired,
|
||||||
notificationsTotal: PropTypes.number.isRequired,
|
notificationsTotal: PropTypes.number.isRequired,
|
||||||
/* eslint-disable react/forbid-prop-types */
|
/* eslint-disable react/forbid-prop-types */
|
||||||
users: PropTypes.array.isRequired,
|
users: PropTypes.array.isRequired,
|
||||||
@@ -256,6 +279,7 @@ Card.propTypes = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Card.defaultProps = {
|
Card.defaultProps = {
|
||||||
|
description: undefined,
|
||||||
dueDate: undefined,
|
dueDate: undefined,
|
||||||
isDueDateCompleted: undefined,
|
isDueDateCompleted: undefined,
|
||||||
stopwatch: undefined,
|
stopwatch: undefined,
|
||||||
|
|||||||
@@ -39,6 +39,13 @@
|
|||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.attachmentContent {
|
||||||
|
color: #6a808b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
padding: 0px 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.attachmentLeft {
|
.attachmentLeft {
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { focusEnd } from '../../../utils/element-helpers';
|
|||||||
|
|
||||||
import styles from './CommentEdit.module.scss';
|
import styles from './CommentEdit.module.scss';
|
||||||
|
|
||||||
const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref) => {
|
const CommentEdit = React.forwardRef(({ defaultData, onUpdate, text, actions }, ref) => {
|
||||||
const [t] = useTranslation();
|
const [t] = useTranslation();
|
||||||
const [isOpened, setIsOpened] = useState(false);
|
const [isOpened, setIsOpened] = useState(false);
|
||||||
const [data, handleFieldChange, setData] = useForm(null);
|
const [data, handleFieldChange, setData] = useForm(null);
|
||||||
@@ -76,7 +76,12 @@ const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref)
|
|||||||
}, [isOpened]);
|
}, [isOpened]);
|
||||||
|
|
||||||
if (!isOpened) {
|
if (!isOpened) {
|
||||||
return children;
|
return (
|
||||||
|
<>
|
||||||
|
{actions}
|
||||||
|
{text}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -101,9 +106,10 @@ const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref)
|
|||||||
});
|
});
|
||||||
|
|
||||||
CommentEdit.propTypes = {
|
CommentEdit.propTypes = {
|
||||||
children: PropTypes.element.isRequired,
|
|
||||||
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||||
onUpdate: PropTypes.func.isRequired,
|
onUpdate: PropTypes.func.isRequired,
|
||||||
|
text: PropTypes.element.isRequired,
|
||||||
|
actions: PropTypes.element.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default React.memo(CommentEdit);
|
export default React.memo(CommentEdit);
|
||||||
|
|||||||
@@ -31,7 +31,18 @@ const ItemComment = React.memo(
|
|||||||
<User name={user.name} avatarUrl={user.avatarUrl} />
|
<User name={user.name} avatarUrl={user.avatarUrl} />
|
||||||
</span>
|
</span>
|
||||||
<div className={classNames(styles.content)}>
|
<div className={classNames(styles.content)}>
|
||||||
|
<CommentEdit
|
||||||
|
ref={commentEdit}
|
||||||
|
defaultData={data}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
text={
|
||||||
|
<div className={styles.text}>
|
||||||
|
<Markdown linkTarget="_blank">{data.text}</Markdown>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
<div className={styles.title}>
|
<div className={styles.title}>
|
||||||
|
<span>
|
||||||
<span className={styles.author}>{user.name}</span>
|
<span className={styles.author}>{user.name}</span>
|
||||||
<span className={styles.date}>
|
<span className={styles.date}>
|
||||||
{t(`format:${getDateFormat(createdAt)}`, {
|
{t(`format:${getDateFormat(createdAt)}`, {
|
||||||
@@ -39,12 +50,7 @@ const ItemComment = React.memo(
|
|||||||
value: createdAt,
|
value: createdAt,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</span>
|
||||||
<CommentEdit ref={commentEdit} defaultData={data} onUpdate={onUpdate}>
|
|
||||||
<>
|
|
||||||
<div className={styles.text}>
|
|
||||||
<Markdown linkTarget="_blank">{data.text}</Markdown>
|
|
||||||
</div>
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Comment.Actions>
|
<Comment.Actions>
|
||||||
<Comment.Action
|
<Comment.Action
|
||||||
@@ -67,8 +73,9 @@ const ItemComment = React.memo(
|
|||||||
</DeletePopup>
|
</DeletePopup>
|
||||||
</Comment.Actions>
|
</Comment.Actions>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
</CommentEdit>
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Comment>
|
</Comment>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,6 +38,12 @@
|
|||||||
|
|
||||||
.title {
|
.title {
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: sticky;
|
||||||
|
top: -1em;
|
||||||
|
background: #f5f6f7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user {
|
.user {
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ const CardModal = React.memo(
|
|||||||
{(description || canEdit) && (
|
{(description || canEdit) && (
|
||||||
<div className={styles.contentModule}>
|
<div className={styles.contentModule}>
|
||||||
<div className={styles.moduleWrapper}>
|
<div className={styles.moduleWrapper}>
|
||||||
<Icon name="align justify" className={styles.moduleIcon} />
|
<Icon name="align left" className={styles.moduleIcon} />
|
||||||
<div className={styles.moduleHeader}>{t('common.description')}</div>
|
<div className={styles.moduleHeader}>{t('common.description')}</div>
|
||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
<DescriptionEdit defaultValue={description} onUpdate={handleDescriptionUpdate}>
|
<DescriptionEdit defaultValue={description} onUpdate={handleDescriptionUpdate}>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import React, { useCallback, useImperativeHandle, useMemo, useState } from 'react';
|
import React, { useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Form } from 'semantic-ui-react';
|
import { Button, Form } from 'semantic-ui-react';
|
||||||
import SimpleMDE from 'react-simplemde-editor';
|
import SimpleMDE from 'react-simplemde-editor';
|
||||||
|
import { useClickAwayListener } from '../../lib/hooks';
|
||||||
|
|
||||||
|
import { useNestedRef } from '../../hooks';
|
||||||
|
|
||||||
import styles from './DescriptionEdit.module.scss';
|
import styles from './DescriptionEdit.module.scss';
|
||||||
|
|
||||||
@@ -11,6 +14,10 @@ const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate },
|
|||||||
const [isOpened, setIsOpened] = useState(false);
|
const [isOpened, setIsOpened] = useState(false);
|
||||||
const [value, setValue] = useState(null);
|
const [value, setValue] = useState(null);
|
||||||
|
|
||||||
|
const editorWrapperRef = useRef(null);
|
||||||
|
const codemirrorRef = useRef(null);
|
||||||
|
const [buttonRef, handleButtonRef] = useNestedRef();
|
||||||
|
|
||||||
const open = useCallback(() => {
|
const open = useCallback(() => {
|
||||||
setIsOpened(true);
|
setIsOpened(true);
|
||||||
setValue(defaultValue || '');
|
setValue(defaultValue || '');
|
||||||
@@ -55,6 +62,28 @@ const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate },
|
|||||||
close();
|
close();
|
||||||
}, [close]);
|
}, [close]);
|
||||||
|
|
||||||
|
const handleAwayClick = useCallback(() => {
|
||||||
|
if (!isOpened) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
close();
|
||||||
|
}, [isOpened, close]);
|
||||||
|
|
||||||
|
const handleClickAwayCancel = useCallback(() => {
|
||||||
|
codemirrorRef.current.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clickAwayProps = useClickAwayListener(
|
||||||
|
[editorWrapperRef, buttonRef],
|
||||||
|
handleAwayClick,
|
||||||
|
handleClickAwayCancel,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGetCodemirrorInstance = useCallback((codemirror) => {
|
||||||
|
codemirrorRef.current = codemirror;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const mdEditorOptions = useMemo(
|
const mdEditorOptions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
autoDownloadFontAwesome: false,
|
autoDownloadFontAwesome: false,
|
||||||
@@ -92,16 +121,20 @@ const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate },
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit}>
|
||||||
|
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||||
|
<div {...clickAwayProps} ref={editorWrapperRef}>
|
||||||
<SimpleMDE
|
<SimpleMDE
|
||||||
value={value}
|
value={value}
|
||||||
options={mdEditorOptions}
|
options={mdEditorOptions}
|
||||||
placeholder={t('common.enterDescription')}
|
placeholder={t('common.enterDescription')}
|
||||||
className={styles.field}
|
className={styles.field}
|
||||||
|
getCodemirrorInstance={handleGetCodemirrorInstance}
|
||||||
onKeyDown={handleFieldKeyDown}
|
onKeyDown={handleFieldKeyDown}
|
||||||
onChange={setValue}
|
onChange={setValue}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div className={styles.controls}>
|
<div className={styles.controls}>
|
||||||
<Button positive content={t('action.save')} />
|
<Button positive ref={handleButtonRef} content={t('action.save')} />
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const DEFAULT_DATA = {
|
|||||||
name: '',
|
name: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MULTIPLE_REGEX = /\s*\r?\n\s*/;
|
||||||
|
|
||||||
const Add = React.forwardRef(({ children, onCreate }, ref) => {
|
const Add = React.forwardRef(({ children, onCreate }, ref) => {
|
||||||
const [t] = useTranslation();
|
const [t] = useTranslation();
|
||||||
const [isOpened, setIsOpened] = useState(false);
|
const [isOpened, setIsOpened] = useState(false);
|
||||||
@@ -29,7 +31,8 @@ const Add = React.forwardRef(({ children, onCreate }, ref) => {
|
|||||||
setIsOpened(false);
|
setIsOpened(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const submit = useCallback(() => {
|
const submit = useCallback(
|
||||||
|
(isMultiple = false) => {
|
||||||
const cleanData = {
|
const cleanData = {
|
||||||
...data,
|
...data,
|
||||||
name: data.name.trim(),
|
name: data.name.trim(),
|
||||||
@@ -40,11 +43,22 @@ const Add = React.forwardRef(({ children, onCreate }, ref) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isMultiple) {
|
||||||
|
cleanData.name.split(MULTIPLE_REGEX).forEach((name) => {
|
||||||
|
onCreate({
|
||||||
|
...cleanData,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
onCreate(cleanData);
|
onCreate(cleanData);
|
||||||
|
}
|
||||||
|
|
||||||
setData(DEFAULT_DATA);
|
setData(DEFAULT_DATA);
|
||||||
focusNameField();
|
focusNameField();
|
||||||
}, [onCreate, data, setData, focusNameField]);
|
},
|
||||||
|
[onCreate, data, setData, focusNameField],
|
||||||
|
);
|
||||||
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
@@ -63,8 +77,7 @@ const Add = React.forwardRef(({ children, onCreate }, ref) => {
|
|||||||
(event) => {
|
(event) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
submit(event.ctrlKey);
|
||||||
submit();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[submit],
|
[submit],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useDidUpdate, useToggle } from '../../lib/hooks';
|
|||||||
import { Input, Popup } from '../../lib/custom-ui';
|
import { Input, Popup } from '../../lib/custom-ui';
|
||||||
|
|
||||||
import { useForm } from '../../hooks';
|
import { useForm } from '../../hooks';
|
||||||
|
import parseTime from '../../utils/parse-time';
|
||||||
|
|
||||||
import styles from './DueDateEditStep.module.scss';
|
import styles from './DueDateEditStep.module.scss';
|
||||||
|
|
||||||
@@ -66,10 +67,7 @@ const DueDateEditStep = React.memo(({ defaultValue, onUpdate, onBack, onClose })
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = t('format:dateTime', {
|
const value = parseTime(data.time, nullableDate);
|
||||||
postProcess: 'parseDate',
|
|
||||||
value: `${data.date} ${data.time}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Number.isNaN(value.getTime())) {
|
if (Number.isNaN(value.getTime())) {
|
||||||
timeField.current.select();
|
timeField.current.select();
|
||||||
@@ -81,7 +79,7 @@ const DueDateEditStep = React.memo(({ defaultValue, onUpdate, onBack, onClose })
|
|||||||
}
|
}
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
}, [defaultValue, onUpdate, onClose, data, nullableDate, t]);
|
}, [defaultValue, onUpdate, onClose, data, nullableDate]);
|
||||||
|
|
||||||
const handleClearClick = useCallback(() => {
|
const handleClearClick = useCallback(() => {
|
||||||
if (defaultValue) {
|
if (defaultValue) {
|
||||||
|
|||||||
@@ -20,12 +20,22 @@ const makeMapStateToProps = () => {
|
|||||||
const allLabels = selectors.selectLabelsForCurrentBoard(state);
|
const allLabels = selectors.selectLabelsForCurrentBoard(state);
|
||||||
const currentUserMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
const currentUserMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||||
|
|
||||||
const { name, dueDate, isDueDateCompleted, stopwatch, coverUrl, boardId, listId, isPersisted } =
|
const {
|
||||||
selectCardById(state, id);
|
name,
|
||||||
|
description,
|
||||||
|
dueDate,
|
||||||
|
isDueDateCompleted,
|
||||||
|
stopwatch,
|
||||||
|
coverUrl,
|
||||||
|
boardId,
|
||||||
|
listId,
|
||||||
|
isPersisted,
|
||||||
|
} = selectCardById(state, id);
|
||||||
|
|
||||||
const users = selectUsersByCardId(state, id);
|
const users = selectUsersByCardId(state, id);
|
||||||
const labels = selectLabelsByCardId(state, id);
|
const labels = selectLabelsByCardId(state, id);
|
||||||
const tasks = selectTasksByCardId(state, id);
|
const tasks = selectTasksByCardId(state, id);
|
||||||
|
const attachmentsTotal = selectors.selectAttachmentsTotalByCardId(state, id);
|
||||||
const notificationsTotal = selectNotificationsTotalByCardId(state, id);
|
const notificationsTotal = selectNotificationsTotalByCardId(state, id);
|
||||||
|
|
||||||
const isCurrentUserEditor =
|
const isCurrentUserEditor =
|
||||||
@@ -35,6 +45,7 @@ const makeMapStateToProps = () => {
|
|||||||
id,
|
id,
|
||||||
index,
|
index,
|
||||||
name,
|
name,
|
||||||
|
description,
|
||||||
dueDate,
|
dueDate,
|
||||||
isDueDateCompleted,
|
isDueDateCompleted,
|
||||||
stopwatch,
|
stopwatch,
|
||||||
@@ -43,6 +54,7 @@ const makeMapStateToProps = () => {
|
|||||||
listId,
|
listId,
|
||||||
projectId,
|
projectId,
|
||||||
isPersisted,
|
isPersisted,
|
||||||
|
attachmentsTotal,
|
||||||
notificationsTotal,
|
notificationsTotal,
|
||||||
users,
|
users,
|
||||||
labels,
|
labels,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import useNestedRef from './use-nested-ref';
|
||||||
import useField from './use-field';
|
import useField from './use-field';
|
||||||
import useForm from './use-form';
|
import useForm from './use-form';
|
||||||
import useSteps from './use-steps';
|
import useSteps from './use-steps';
|
||||||
import useModal from './use-modal';
|
import useModal from './use-modal';
|
||||||
import useClosableForm from './use-closable-form';
|
import useClosableForm from './use-closable-form';
|
||||||
|
|
||||||
export { useField, useForm, useSteps, useModal, useClosableForm };
|
export { useNestedRef, useField, useForm, useSteps, useModal, useClosableForm };
|
||||||
|
|||||||
14
client/src/hooks/use-nested-ref.js
Normal file
14
client/src/hooks/use-nested-ref.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
export default (nestedRefName = 'ref') => {
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
const handleRef = useCallback(
|
||||||
|
(element) => {
|
||||||
|
ref.current = element?.[nestedRefName].current;
|
||||||
|
},
|
||||||
|
[nestedRefName],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [ref, handleRef];
|
||||||
|
};
|
||||||
@@ -2,5 +2,6 @@ import usePrevious from './use-previous';
|
|||||||
import useToggle from './use-toggle';
|
import useToggle from './use-toggle';
|
||||||
import useForceUpdate from './use-force-update';
|
import useForceUpdate from './use-force-update';
|
||||||
import useDidUpdate from './use-did-update';
|
import useDidUpdate from './use-did-update';
|
||||||
|
import useClickAwayListener from './use-click-away-listener';
|
||||||
|
|
||||||
export { usePrevious, useToggle, useForceUpdate, useDidUpdate };
|
export { usePrevious, useToggle, useForceUpdate, useDidUpdate, useClickAwayListener };
|
||||||
|
|||||||
45
client/src/lib/hooks/use-click-away-listener.js
Normal file
45
client/src/lib/hooks/use-click-away-listener.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
export default (elementRefs, onAwayClick, onCancel) => {
|
||||||
|
const pressedElement = useRef(null);
|
||||||
|
|
||||||
|
const handlePress = useCallback((event) => {
|
||||||
|
pressedElement.current = event.target;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEvent = (event) => {
|
||||||
|
const element = elementRefs.find(({ current }) => current?.contains(event.target))?.current;
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
if (!pressedElement.current || pressedElement.current !== element) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
} else if (pressedElement.current) {
|
||||||
|
onCancel();
|
||||||
|
} else {
|
||||||
|
onAwayClick();
|
||||||
|
}
|
||||||
|
|
||||||
|
pressedElement.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', handleEvent, true);
|
||||||
|
document.addEventListener('touchend', handleEvent, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mouseup', handleEvent, true);
|
||||||
|
document.removeEventListener('touchend', handleEvent, true);
|
||||||
|
};
|
||||||
|
}, [onAwayClick, onCancel]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const props = useMemo(
|
||||||
|
() => ({
|
||||||
|
onMouseDown: handlePress,
|
||||||
|
onTouchStart: handlePress,
|
||||||
|
}),
|
||||||
|
[handlePress],
|
||||||
|
);
|
||||||
|
|
||||||
|
return props;
|
||||||
|
};
|
||||||
@@ -184,6 +184,7 @@ export default {
|
|||||||
addTask: 'Přidat úkol',
|
addTask: 'Přidat úkol',
|
||||||
addToCard: 'Přidat na kartu',
|
addToCard: 'Přidat na kartu',
|
||||||
addUser: 'Přidat uživatele',
|
addUser: 'Přidat uživatele',
|
||||||
|
copyLink_title: 'Zkopírovat odkaz',
|
||||||
createBoard: 'Vytvořit tabuli',
|
createBoard: 'Vytvořit tabuli',
|
||||||
createFile: 'Vytvořit soubor',
|
createFile: 'Vytvořit soubor',
|
||||||
createLabel: 'Vytvořit štítek',
|
createLabel: 'Vytvořit štítek',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export default {
|
|||||||
common: {
|
common: {
|
||||||
emailOrUsername: 'E-mail nebo uživatelské jméno',
|
emailOrUsername: 'E-mail nebo uživatelské jméno',
|
||||||
invalidEmailOrUsername: 'Nesprávný e-mail nebo uživatelské jméno',
|
invalidEmailOrUsername: 'Nesprávný e-mail nebo uživatelské jméno',
|
||||||
|
invalidCredentials: 'Neplatné přihlašovací údaje',
|
||||||
invalidPassword: 'Nesprávné heslo',
|
invalidPassword: 'Nesprávné heslo',
|
||||||
logInToPlanka: 'Přihlásit se do Planka',
|
logInToPlanka: 'Přihlásit se do Planka',
|
||||||
noInternetConnection: 'Bez připojení k internetu',
|
noInternetConnection: 'Bez připojení k internetu',
|
||||||
@@ -11,6 +12,7 @@ export default {
|
|||||||
projectManagement: 'Správa projektu',
|
projectManagement: 'Správa projektu',
|
||||||
serverConnectionFailed: 'Připojení k serveru selhalo',
|
serverConnectionFailed: 'Připojení k serveru selhalo',
|
||||||
unknownError: 'Neznámá chyba, zkuste to později',
|
unknownError: 'Neznámá chyba, zkuste to později',
|
||||||
|
useSingleSignOn: 'Použít jednorázové přihlášení',
|
||||||
},
|
},
|
||||||
|
|
||||||
action: {
|
action: {
|
||||||
|
|||||||
@@ -115,6 +115,23 @@ export const makeSelectTasksByCardId = () =>
|
|||||||
|
|
||||||
export const selectTasksByCardId = makeSelectTasksByCardId();
|
export const selectTasksByCardId = makeSelectTasksByCardId();
|
||||||
|
|
||||||
|
export const makeSelectAttachmentsTotalByCardId = () =>
|
||||||
|
createSelector(
|
||||||
|
orm,
|
||||||
|
(_, id) => id,
|
||||||
|
({ Card }, id) => {
|
||||||
|
const cardModel = Card.withId(id);
|
||||||
|
|
||||||
|
if (!cardModel) {
|
||||||
|
return cardModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cardModel.attachments.count();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectAttachmentsTotalByCardId = makeSelectAttachmentsTotalByCardId();
|
||||||
|
|
||||||
export const makeSelectLastActivityIdByCardId = () =>
|
export const makeSelectLastActivityIdByCardId = () =>
|
||||||
createSelector(
|
createSelector(
|
||||||
orm,
|
orm,
|
||||||
@@ -334,6 +351,8 @@ export default {
|
|||||||
selectTaskIdsByCardId,
|
selectTaskIdsByCardId,
|
||||||
makeSelectTasksByCardId,
|
makeSelectTasksByCardId,
|
||||||
selectTasksByCardId,
|
selectTasksByCardId,
|
||||||
|
makeSelectAttachmentsTotalByCardId,
|
||||||
|
selectAttachmentsTotalByCardId,
|
||||||
makeSelectLastActivityIdByCardId,
|
makeSelectLastActivityIdByCardId,
|
||||||
selectLastActivityIdByCardId,
|
selectLastActivityIdByCardId,
|
||||||
makeSelectNotificationsByCardId,
|
makeSelectNotificationsByCardId,
|
||||||
|
|||||||
102
client/src/utils/parse-time.js
Normal file
102
client/src/utils/parse-time.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import parseDate from 'date-fns/parse';
|
||||||
|
|
||||||
|
const TIME_REGEX =
|
||||||
|
/^((\d{1,2})((:|\.)?(\d{1,2}))?)(a|p|(am|a\.m\.|midnight|mi|pm|p\.m\.|noon|n))?$/;
|
||||||
|
|
||||||
|
const ALTERNATIVE_AM_MERIDIEMS_SET = new Set(['am', 'a.m.', 'midnight', 'mi']);
|
||||||
|
const ALTERNATIVE_PM_MERIDIEMS_SET = new Set(['pm', 'p.m.', 'noon', 'n']);
|
||||||
|
|
||||||
|
const TimeFormats = {
|
||||||
|
TWENTY_FOUR_HOUR: 'twentyFourHour',
|
||||||
|
TWELVE_HOUR: 'twelveHour',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PATTERNS_GROUPS_BY_TIME_FORMAT = {
|
||||||
|
[TimeFormats.TWENTY_FOUR_HOUR]: {
|
||||||
|
byNumbersTotal: {
|
||||||
|
1: ['H'],
|
||||||
|
2: ['HH'],
|
||||||
|
3: ['Hmm'],
|
||||||
|
4: ['HHmm'],
|
||||||
|
},
|
||||||
|
withDelimiter: ['H:m', 'H:mm', 'HH:m', 'HH:mm'],
|
||||||
|
},
|
||||||
|
[TimeFormats.TWELVE_HOUR]: {
|
||||||
|
byNumbersTotal: {
|
||||||
|
1: ['haaaaa'],
|
||||||
|
2: ['hhaaaaa'],
|
||||||
|
3: ['hmmaaaaa'],
|
||||||
|
4: ['hhmmaaaaa'],
|
||||||
|
},
|
||||||
|
withDelimiter: ['h:maaaaa', 'h:mmaaaaa', 'hh:maaaaa', 'hh:mmaaaaa'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const INVALID_DATE = new Date('invalid-date');
|
||||||
|
|
||||||
|
const normalizeDelimeter = (delimeter) => (delimeter === '.' ? ':' : delimeter);
|
||||||
|
|
||||||
|
const normalizeMeridiem = (meridiem, alternativeMeridiem) => {
|
||||||
|
if (meridiem && alternativeMeridiem) {
|
||||||
|
if (ALTERNATIVE_AM_MERIDIEMS_SET.has(alternativeMeridiem)) {
|
||||||
|
return 'a';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ALTERNATIVE_PM_MERIDIEMS_SET.has(alternativeMeridiem)) {
|
||||||
|
return 'p';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return meridiem;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeTimeString = (hours, minutes, delimeter, meridiem) => {
|
||||||
|
let timeString = hours;
|
||||||
|
if (delimeter) {
|
||||||
|
timeString += delimeter;
|
||||||
|
}
|
||||||
|
if (minutes) {
|
||||||
|
timeString += minutes;
|
||||||
|
}
|
||||||
|
if (meridiem) {
|
||||||
|
timeString += meridiem;
|
||||||
|
}
|
||||||
|
|
||||||
|
return timeString;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (string, referenceDate) => {
|
||||||
|
const match = string.replace(/\s/g, '').toLowerCase().match(TIME_REGEX);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return INVALID_DATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, hoursAndMinutes, hours, , delimeter, minutes, meridiem, alternativeMeridiem] = match;
|
||||||
|
|
||||||
|
const normalizedDelimeter = normalizeDelimeter(delimeter);
|
||||||
|
const normalizedMeridiem = normalizeMeridiem(meridiem, alternativeMeridiem);
|
||||||
|
|
||||||
|
const timeString = makeTimeString(hours, minutes, normalizedDelimeter, normalizedMeridiem);
|
||||||
|
|
||||||
|
const timeFormat = meridiem ? TimeFormats.TWELVE_HOUR : TimeFormats.TWENTY_FOUR_HOUR;
|
||||||
|
const patternsGroups = PATTERNS_GROUPS_BY_TIME_FORMAT[timeFormat];
|
||||||
|
|
||||||
|
const patterns = delimeter
|
||||||
|
? patternsGroups.withDelimiter
|
||||||
|
: patternsGroups.byNumbersTotal[hoursAndMinutes.length];
|
||||||
|
|
||||||
|
if (!referenceDate) {
|
||||||
|
referenceDate = new Date(); // eslint-disable-line no-param-reassign
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < patterns.length; i += 1) {
|
||||||
|
const parsedDate = parseDate(timeString, patterns[i], referenceDate);
|
||||||
|
|
||||||
|
if (!Number.isNaN(parsedDate.getTime())) {
|
||||||
|
return parsedDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return INVALID_DATE;
|
||||||
|
};
|
||||||
@@ -1 +1 @@
|
|||||||
export default '1.23.2';
|
export default '1.24.0';
|
||||||
|
|||||||
12
client/tests/acceptance/config.js
Normal file
12
client/tests/acceptance/config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
module.exports = {
|
||||||
|
// environment
|
||||||
|
adminUser: {
|
||||||
|
email: 'demo@demo.demo',
|
||||||
|
password: 'demo',
|
||||||
|
},
|
||||||
|
baseUrl: process.env.BASE_URL ?? 'http://localhost:1337/',
|
||||||
|
// playwright
|
||||||
|
slowMo: parseInt(process.env.SLOW_MO, 10) || 1000,
|
||||||
|
timeout: parseInt(process.env.TIMEOUT, 10) || 6000,
|
||||||
|
headless: process.env.HEADLESS !== 'true',
|
||||||
|
};
|
||||||
35
client/tests/acceptance/cucumber.conf.js
Normal file
35
client/tests/acceptance/cucumber.conf.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// cucumber.conf.js file
|
||||||
|
|
||||||
|
const { Before, BeforeAll, AfterAll, After, setDefaultTimeout } = require('@cucumber/cucumber');
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
const { deleteProject } = require('./testHelpers/apiHelpers');
|
||||||
|
const config = require('./config');
|
||||||
|
|
||||||
|
setDefaultTimeout(config.timeout);
|
||||||
|
|
||||||
|
// launch the browser
|
||||||
|
BeforeAll(async function () {
|
||||||
|
global.browser = await chromium.launch({
|
||||||
|
// makes true for CI
|
||||||
|
headless: config.headless,
|
||||||
|
slowMo: config.slowMo,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// close the browser
|
||||||
|
AfterAll(async function () {
|
||||||
|
await global.browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a new browser context and page per scenario
|
||||||
|
Before(async function () {
|
||||||
|
global.context = await global.browser.newContext();
|
||||||
|
global.page = await global.context.newPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup after each scenario
|
||||||
|
After(async function () {
|
||||||
|
await deleteProject();
|
||||||
|
await global.page.close();
|
||||||
|
await global.context.close();
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Feature: dashboard
|
||||||
|
As a admin
|
||||||
|
I want to create a project
|
||||||
|
So that I can manage project
|
||||||
|
|
||||||
|
Scenario: create a new project
|
||||||
|
Given user has browsed to the login page
|
||||||
|
And user has logged in with email "demo@demo.demo" and password "demo"
|
||||||
|
When the user creates a project with name "testproject" using the webUI
|
||||||
|
Then the created project "testproject" should be opened
|
||||||
27
client/tests/acceptance/features/webUILogin/login.feature
Normal file
27
client/tests/acceptance/features/webUILogin/login.feature
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
Feature: login
|
||||||
|
As a admin
|
||||||
|
I want to log in
|
||||||
|
So that I can manage project
|
||||||
|
|
||||||
|
|
||||||
|
Scenario: User logs in with valid credentials
|
||||||
|
Given user has browsed to the login page
|
||||||
|
When user logs in with username "demo@demo.demo" and password "demo" using the webUI
|
||||||
|
Then the user should be in dashboard page
|
||||||
|
|
||||||
|
|
||||||
|
Scenario Outline: login with invalid username and invalid password
|
||||||
|
Given user has browsed to the login page
|
||||||
|
When user logs in with username "<username>" and password "<password>" using the webUI
|
||||||
|
Then user should see the error message "<message>"
|
||||||
|
Examples:
|
||||||
|
| username | password | message |
|
||||||
|
| spiderman | spidy123 | Invalid credentials |
|
||||||
|
| ironman | iron123 | Invalid credentials |
|
||||||
|
| aquaman | aqua123 | Invalid credentials |
|
||||||
|
|
||||||
|
|
||||||
|
Scenario: User can log out
|
||||||
|
Given user has logged in with email "demo@demo.demo" and password "demo"
|
||||||
|
When user logs out using the webUI
|
||||||
|
Then the user should be in the login page
|
||||||
16
client/tests/acceptance/pageObjects/DashboardPage.js
Normal file
16
client/tests/acceptance/pageObjects/DashboardPage.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
class DashboardPage {
|
||||||
|
constructor() {
|
||||||
|
this.createProjectIconSelector = `.Projects_addTitle__tXhB4`;
|
||||||
|
this.projectTitleInputSelector = `input[name="name"]`;
|
||||||
|
this.createProjectButtonSelector = `//button[text()="Create project"]`;
|
||||||
|
this.projectTitleSelector = `//div[@class="item Header_item__OOEY7 Header_title__l+wMf"][text()="%s"]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProject(project) {
|
||||||
|
await page.click(this.createProjectIconSelector);
|
||||||
|
await page.fill(this.projectTitleInputSelector, project);
|
||||||
|
await page.click(this.createProjectButtonSelector);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = DashboardPage;
|
||||||
38
client/tests/acceptance/pageObjects/LoginPage.js
Normal file
38
client/tests/acceptance/pageObjects/LoginPage.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const config = require(`../config`);
|
||||||
|
|
||||||
|
class LoginPage {
|
||||||
|
constructor() {
|
||||||
|
// url
|
||||||
|
this.homeUrl = config.baseUrl;
|
||||||
|
this.loginUrl = `${this.homeUrl}login`;
|
||||||
|
|
||||||
|
// selectors
|
||||||
|
this.loginButtonSelector = `//i[@class="right arrow icon"]`;
|
||||||
|
this.usernameSelector = `//input[@name='emailOrUsername']`;
|
||||||
|
this.passwordSelector = `//input[@name='password']`;
|
||||||
|
this.errorMessageSelector = `//div[@class='ui error visible message']`;
|
||||||
|
this.userActionSelector = `//span[@class="User_initials__9Wp90"]`;
|
||||||
|
this.logOutSelector = `//a[@class="item UserStep_menuItem__5pvtT"][contains(text(),'Log Out')]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async goToLoginUrl() {
|
||||||
|
await page.goto(this.loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async logOut() {
|
||||||
|
await page.click(this.userActionSelector);
|
||||||
|
await page.click(this.logOutSelector);
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(username, password) {
|
||||||
|
await page.fill(this.usernameSelector, username);
|
||||||
|
await page.fill(this.passwordSelector, password);
|
||||||
|
await page.click(this.loginButtonSelector);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getErrorMessage() {
|
||||||
|
return page.innerText(this.errorMessageSelector);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = LoginPage;
|
||||||
17
client/tests/acceptance/stepDefinitions/dashBoardContext.js
Normal file
17
client/tests/acceptance/stepDefinitions/dashBoardContext.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const { When, Then } = require('@cucumber/cucumber');
|
||||||
|
const util = require('util');
|
||||||
|
const { expect } = require('playwright/test');
|
||||||
|
|
||||||
|
const DashboardPage = require('../pageObjects/DashboardPage');
|
||||||
|
|
||||||
|
const dashboardPage = new DashboardPage();
|
||||||
|
|
||||||
|
When('the user creates a project with name {string} using the webUI', async function (project) {
|
||||||
|
await dashboardPage.createProject(project);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the created project {string} should be opened', async function (project) {
|
||||||
|
expect(
|
||||||
|
await page.locator(util.format(dashboardPage.projectTitleSelector, project)),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
53
client/tests/acceptance/stepDefinitions/loginContext.js
Normal file
53
client/tests/acceptance/stepDefinitions/loginContext.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
const { Given, When, Then } = require('@cucumber/cucumber');
|
||||||
|
|
||||||
|
// import expect for assertion
|
||||||
|
const { expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
// import assert
|
||||||
|
const assert = require('assert');
|
||||||
|
|
||||||
|
const LoginPage = require('../pageObjects/LoginPage');
|
||||||
|
|
||||||
|
const loginPage = new LoginPage();
|
||||||
|
|
||||||
|
Given('user has browsed to the login page', async function () {
|
||||||
|
await loginPage.goToLoginUrl();
|
||||||
|
await expect(page).toHaveURL(loginPage.loginUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
Given(
|
||||||
|
'user has logged in with email {string} and password {string}',
|
||||||
|
async function (username, password) {
|
||||||
|
await loginPage.goToLoginUrl();
|
||||||
|
await loginPage.login(username, password);
|
||||||
|
await expect(page).toHaveURL(loginPage.homeUrl);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
When(
|
||||||
|
'user logs in with username {string} and password {string} using the webUI',
|
||||||
|
async function (username, password) {
|
||||||
|
await loginPage.login(username, password);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Then('the user should be in dashboard page', async function () {
|
||||||
|
await expect(page).toHaveURL(loginPage.homeUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('user should see the error message {string}', async function (errorMessage) {
|
||||||
|
const actualErrorMessage = await loginPage.getErrorMessage();
|
||||||
|
assert.equal(
|
||||||
|
actualErrorMessage,
|
||||||
|
errorMessage,
|
||||||
|
`Expected message to be "${errorMessage}" but receive "${actualErrorMessage}"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('user logs out using the webUI', async function () {
|
||||||
|
await loginPage.logOut();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the user should be in the login page', async function () {
|
||||||
|
await expect(page).toHaveURL(loginPage.loginUrl);
|
||||||
|
});
|
||||||
57
client/tests/acceptance/testHelpers/apiHelpers.js
Normal file
57
client/tests/acceptance/testHelpers/apiHelpers.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
async function getXauthToken() {
|
||||||
|
try {
|
||||||
|
const res = await axios.post(
|
||||||
|
`${config.baseUrl}api/access-tokens`,
|
||||||
|
{
|
||||||
|
emailOrUsername: config.adminUser.email,
|
||||||
|
password: config.adminUser.password,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return res.data.item;
|
||||||
|
} catch (error) {
|
||||||
|
return `Error requesting access token: ${error.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProjectIDs() {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${config.baseUrl}api/projects`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${await getXauthToken()}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res.data.items.map((project) => project.id);
|
||||||
|
} catch (error) {
|
||||||
|
return `Error requesting projectIDs: ${error.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProject() {
|
||||||
|
try {
|
||||||
|
const projectIDs = await getProjectIDs();
|
||||||
|
await Promise.all(
|
||||||
|
projectIDs.map(async (project) => {
|
||||||
|
await axios.delete(`${config.baseUrl}api/projects/${project}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${await getXauthToken()}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return `Error deleting project: ${error.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
deleteProject,
|
||||||
|
};
|
||||||
23
client/tests/setup-symlinks.sh
Executable file
23
client/tests/setup-symlinks.sh
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# This script sets up symbolic links between the client build files and the server directories,
|
||||||
|
|
||||||
|
# Navigate to the root directory of the git repository
|
||||||
|
cd "$(git rev-parse --show-toplevel)" || { echo "Failed to navigate to the git repository root"; exit 1; }
|
||||||
|
|
||||||
|
# Store paths for the client build, server public directory, and server views directory
|
||||||
|
CLIENT_PATH=$(pwd)/client/build
|
||||||
|
SERVER_PUBLIC_PATH=$(pwd)/server/public
|
||||||
|
SERVER_VIEWS_PATH=$(pwd)/server/views
|
||||||
|
|
||||||
|
# Create symbolic links for the necessary client assets in the server's public and views directories
|
||||||
|
ln -s ${CLIENT_PATH}/asset-manifest.json ${SERVER_PUBLIC_PATH}/asset-manifest.json && echo "Linked asset-manifest.json successfully"
|
||||||
|
ln -s ${CLIENT_PATH}/favicon.ico ${SERVER_PUBLIC_PATH}/favicon.ico && echo "Linked favicon.ico successfully"
|
||||||
|
ln -s ${CLIENT_PATH}/logo192.png ${SERVER_PUBLIC_PATH}/logo192.png && echo "Linked logo192.png successfully"
|
||||||
|
ln -s ${CLIENT_PATH}/logo512.png ${SERVER_PUBLIC_PATH}/logo512.png && echo "Linked logo512.png successfully"
|
||||||
|
ln -s ${CLIENT_PATH}/manifest.json ${SERVER_PUBLIC_PATH}/manifest.json && echo "Linked manifest.json successfully"
|
||||||
|
ln -s ${CLIENT_PATH}/robots.txt ${SERVER_PUBLIC_PATH}/robots.txt && echo "Linked robots.txt successfully"
|
||||||
|
ln -s ${CLIENT_PATH}/static ${SERVER_PUBLIC_PATH}/static && echo "Linked static folder successfully"
|
||||||
|
ln -s ${CLIENT_PATH}/index.html ${SERVER_VIEWS_PATH}/index.ejs && echo "Linked index.html to index.ejs successfully"
|
||||||
|
|
||||||
|
echo "Setup symbolic links completed successfully."
|
||||||
@@ -2,7 +2,7 @@ version: '3'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- db-data:/var/lib/postgresql/data
|
- db-data:/var/lib/postgresql/data
|
||||||
|
|||||||
@@ -25,9 +25,15 @@ services:
|
|||||||
# - KNEX_REJECT_UNAUTHORIZED_SSL_CERTIFICATE=false
|
# - KNEX_REJECT_UNAUTHORIZED_SSL_CERTIFICATE=false
|
||||||
|
|
||||||
# - SHOW_DETAILED_AUTH_ERRORS=false # Set to true to show more detailed authentication error messages. It should not be enabled without a rate limiter for security reasons.
|
# - SHOW_DETAILED_AUTH_ERRORS=false # Set to true to show more detailed authentication error messages. It should not be enabled without a rate limiter for security reasons.
|
||||||
|
|
||||||
# - ALLOW_ALL_TO_CREATE_PROJECTS=true
|
# - ALLOW_ALL_TO_CREATE_PROJECTS=true
|
||||||
|
|
||||||
|
# - S3_ENDPOINT=
|
||||||
|
# - S3_REGION=
|
||||||
|
# - S3_ACCESS_KEY_ID=
|
||||||
|
# - S3_SECRET_ACCESS_KEY=
|
||||||
|
# - S3_BUCKET=
|
||||||
|
# - S3_FORCE_PATH_STYLE=true
|
||||||
|
|
||||||
# - OIDC_ISSUER=
|
# - OIDC_ISSUER=
|
||||||
# - OIDC_CLIENT_ID=
|
# - OIDC_CLIENT_ID=
|
||||||
# - OIDC_CLIENT_SECRET=
|
# - OIDC_CLIENT_SECRET=
|
||||||
@@ -67,6 +73,12 @@ services:
|
|||||||
|
|
||||||
# - SLACK_BOT_TOKEN=
|
# - SLACK_BOT_TOKEN=
|
||||||
# - SLACK_CHANNEL_ID=
|
# - SLACK_CHANNEL_ID=
|
||||||
|
|
||||||
|
# - GOOGLE_CHAT_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# - TELEGRAM_BOT_TOKEN=
|
||||||
|
# - TELEGRAM_CHAT_ID=
|
||||||
|
# - TELEGRAM_THREAD_ID=
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
command: ["sh", "-c", "npm run start"]
|
command: ["sh", "-c", "npm run start"]
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -110,7 +122,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:latest
|
image: postgres:16-alpine
|
||||||
volumes:
|
volumes:
|
||||||
- db-data:/var/lib/postgresql/data
|
- db-data:/var/lib/postgresql/data
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -32,9 +32,15 @@ services:
|
|||||||
# - DEFAULT_ADMIN_USERNAME=demo
|
# - DEFAULT_ADMIN_USERNAME=demo
|
||||||
|
|
||||||
# - SHOW_DETAILED_AUTH_ERRORS=false # Set to true to show more detailed authentication error messages. It should not be enabled without a rate limiter for security reasons.
|
# - SHOW_DETAILED_AUTH_ERRORS=false # Set to true to show more detailed authentication error messages. It should not be enabled without a rate limiter for security reasons.
|
||||||
|
|
||||||
# - ALLOW_ALL_TO_CREATE_PROJECTS=true
|
# - ALLOW_ALL_TO_CREATE_PROJECTS=true
|
||||||
|
|
||||||
|
# - S3_ENDPOINT=
|
||||||
|
# - S3_REGION=
|
||||||
|
# - S3_ACCESS_KEY_ID=
|
||||||
|
# - S3_SECRET_ACCESS_KEY=
|
||||||
|
# - S3_BUCKET=
|
||||||
|
# - S3_FORCE_PATH_STYLE=true
|
||||||
|
|
||||||
# - OIDC_ISSUER=
|
# - OIDC_ISSUER=
|
||||||
# - OIDC_CLIENT_ID=
|
# - OIDC_CLIENT_ID=
|
||||||
# - OIDC_CLIENT_SECRET=
|
# - OIDC_CLIENT_SECRET=
|
||||||
@@ -74,13 +80,18 @@ services:
|
|||||||
|
|
||||||
# - SLACK_BOT_TOKEN=
|
# - SLACK_BOT_TOKEN=
|
||||||
# - SLACK_CHANNEL_ID=
|
# - SLACK_CHANNEL_ID=
|
||||||
|
|
||||||
# - GOOGLE_CHAT_WEBHOOK_URL=
|
# - GOOGLE_CHAT_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# - TELEGRAM_BOT_TOKEN=
|
||||||
|
# - TELEGRAM_CHAT_ID=
|
||||||
|
# - TELEGRAM_THREAD_ID=
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:14-alpine
|
image: postgres:16-alpine
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
volumes:
|
volumes:
|
||||||
- db-data:/var/lib/postgresql/data
|
- db-data:/var/lib/postgresql/data
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "planka",
|
"name": "planka",
|
||||||
"version": "1.23.2",
|
"version": "1.24.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "planka",
|
"name": "planka",
|
||||||
"version": "1.23.2",
|
"version": "1.24.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planka",
|
"name": "planka",
|
||||||
"version": "1.23.2",
|
"version": "1.24.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"homepage": "https://plankanban.github.io/planka",
|
"homepage": "https://plankanban.github.io/planka",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"client:test": "npm test --prefix client",
|
"client:test": "npm test --prefix client",
|
||||||
"docker:build": "docker build -t ghcr.io/plankanban/planka:local -f Dockerfile .",
|
"docker:build": "docker build -t ghcr.io/plankanban/planka:local -f Dockerfile .",
|
||||||
"docker:build:base": "docker build -t ghcr.io/plankanban/planka:base-local -f Dockerfile.base .",
|
"docker:build:base": "docker build -t ghcr.io/plankanban/planka:base-local -f Dockerfile.base .",
|
||||||
"gv": "genversion --source ./ --template client/version-template.ejs client/src/version.js",
|
"gv": "npm i --package-lock-only --ignore-scripts && genversion --source ./ --template client/version-template.ejs client/src/version.js",
|
||||||
"postinstall": "(cd server && npm i && cd ../client && npm i)",
|
"postinstall": "(cd server && npm i && cd ../client && npm i)",
|
||||||
"lint": "npm run server:lint && npm run client:lint",
|
"lint": "npm run server:lint && npm run client:lint",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"test": "npm run server:test && npm run client:test"
|
"test": "npm run server:test && npm run client:test"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"client/**/*.{js,jsx}": [
|
"client/src/**/*.{js,jsx}": [
|
||||||
"npm run client:lint"
|
"npm run client:lint"
|
||||||
],
|
],
|
||||||
"server/**/*.js": [
|
"server/**/*.js": [
|
||||||
|
|||||||
@@ -25,9 +25,15 @@ SECRET_KEY=notsecretkey
|
|||||||
# DEFAULT_ADMIN_USERNAME=demo
|
# DEFAULT_ADMIN_USERNAME=demo
|
||||||
|
|
||||||
# SHOW_DETAILED_AUTH_ERRORS=false # Set to true to show more detailed authentication error messages. It should not be enabled without a rate limiter for security reasons.
|
# SHOW_DETAILED_AUTH_ERRORS=false # Set to true to show more detailed authentication error messages. It should not be enabled without a rate limiter for security reasons.
|
||||||
|
|
||||||
# ALLOW_ALL_TO_CREATE_PROJECTS=true
|
# ALLOW_ALL_TO_CREATE_PROJECTS=true
|
||||||
|
|
||||||
|
# S3_ENDPOINT=
|
||||||
|
# S3_REGION=
|
||||||
|
# S3_ACCESS_KEY_ID=
|
||||||
|
# S3_SECRET_ACCESS_KEY=
|
||||||
|
# S3_BUCKET=
|
||||||
|
# S3_FORCE_PATH_STYLE=true
|
||||||
|
|
||||||
# OIDC_ISSUER=
|
# OIDC_ISSUER=
|
||||||
# OIDC_CLIENT_ID=
|
# OIDC_CLIENT_ID=
|
||||||
# OIDC_CLIENT_SECRET=
|
# OIDC_CLIENT_SECRET=
|
||||||
@@ -65,8 +71,13 @@ SECRET_KEY=notsecretkey
|
|||||||
|
|
||||||
# SLACK_BOT_TOKEN=
|
# SLACK_BOT_TOKEN=
|
||||||
# SLACK_CHANNEL_ID=
|
# SLACK_CHANNEL_ID=
|
||||||
|
|
||||||
# GOOGLE_CHAT_WEBHOOK_URL=
|
# GOOGLE_CHAT_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# TELEGRAM_BOT_TOKEN=
|
||||||
|
# TELEGRAM_CHAT_ID=
|
||||||
|
# TELEGRAM_THREAD_ID=
|
||||||
|
|
||||||
## Do not edit this
|
## Do not edit this
|
||||||
|
|
||||||
TZ=UTC
|
TZ=UTC
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const Errors = {
|
const Errors = {
|
||||||
ATTACHMENT_NOT_FOUND: {
|
ATTACHMENT_NOT_FOUND: {
|
||||||
attachmentNotFound: 'Attachment not found',
|
attachmentNotFound: 'Attachment not found',
|
||||||
@@ -46,20 +43,20 @@ module.exports = {
|
|||||||
throw Errors.ATTACHMENT_NOT_FOUND;
|
throw Errors.ATTACHMENT_NOT_FOUND;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = path.join(
|
const fileManager = sails.hooks['file-manager'].getInstance();
|
||||||
sails.config.custom.attachmentsPath,
|
|
||||||
attachment.dirname,
|
|
||||||
'thumbnails',
|
|
||||||
`cover-256.${attachment.image.thumbnailsExtension}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!fs.existsSync(filePath)) {
|
let readStream;
|
||||||
|
try {
|
||||||
|
readStream = await fileManager.read(
|
||||||
|
`${sails.config.custom.attachmentsPathSegment}/${attachment.dirname}/thumbnails/cover-256.${attachment.image.thumbnailsExtension}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
throw Errors.ATTACHMENT_NOT_FOUND;
|
throw Errors.ATTACHMENT_NOT_FOUND;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.res.type('image/jpeg');
|
this.res.type('image/jpeg');
|
||||||
this.res.set('Cache-Control', 'private, max-age=900'); // TODO: move to config
|
this.res.set('Cache-Control', 'private, max-age=900'); // TODO: move to config
|
||||||
|
|
||||||
return exits.success(fs.createReadStream(filePath));
|
return exits.success(readStream);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const Errors = {
|
const Errors = {
|
||||||
@@ -42,13 +41,14 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = path.join(
|
const fileManager = sails.hooks['file-manager'].getInstance();
|
||||||
sails.config.custom.attachmentsPath,
|
|
||||||
attachment.dirname,
|
|
||||||
attachment.filename,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!fs.existsSync(filePath)) {
|
let readStream;
|
||||||
|
try {
|
||||||
|
readStream = await fileManager.read(
|
||||||
|
`${sails.config.custom.attachmentsPathSegment}/${attachment.dirname}/${attachment.filename}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
throw Errors.ATTACHMENT_NOT_FOUND;
|
throw Errors.ATTACHMENT_NOT_FOUND;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +58,6 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
this.res.set('Cache-Control', 'private, max-age=900'); // TODO: move to config
|
this.res.set('Cache-Control', 'private, max-age=900'); // TODO: move to config
|
||||||
|
|
||||||
return exits.success(fs.createReadStream(filePath));
|
return exits.success(readStream);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ const valuesValidator = (value) => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildAndSendMessage = async (card, action, actorUser, send) => {
|
const truncateString = (string, maxLength = 30) =>
|
||||||
|
string.length > maxLength ? `${string.substring(0, 30)}...` : string;
|
||||||
|
|
||||||
|
const buildAndSendMarkdownMessage = async (card, action, actorUser, send) => {
|
||||||
const cardLink = `<${sails.config.custom.baseUrl}/cards/${card.id}|${card.name}>`;
|
const cardLink = `<${sails.config.custom.baseUrl}/cards/${card.id}|${card.name}>`;
|
||||||
|
|
||||||
let markdown;
|
let markdown;
|
||||||
@@ -28,6 +31,7 @@ const buildAndSendMessage = async (card, action, actorUser, send) => {
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
case Action.Types.COMMENT_CARD:
|
case Action.Types.COMMENT_CARD:
|
||||||
|
// TODO: truncate text?
|
||||||
markdown = `*${actorUser.name}* commented on ${cardLink}:\n>${action.data.text}`;
|
markdown = `*${actorUser.name}* commented on ${cardLink}:\n>${action.data.text}`;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
@@ -38,6 +42,31 @@ const buildAndSendMessage = async (card, action, actorUser, send) => {
|
|||||||
await send(markdown);
|
await send(markdown);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildAndSendHtmlMessage = async (card, action, actorUser, send) => {
|
||||||
|
const cardLink = `<a href="${sails.config.custom.baseUrl}/cards/${card.id}">${card.name}</a>`;
|
||||||
|
|
||||||
|
let html;
|
||||||
|
switch (action.type) {
|
||||||
|
case Action.Types.CREATE_CARD:
|
||||||
|
html = `${cardLink} was created by ${actorUser.name} in <b>${action.data.list.name}</b>`;
|
||||||
|
|
||||||
|
break;
|
||||||
|
case Action.Types.MOVE_CARD:
|
||||||
|
html = `${cardLink} was moved by ${actorUser.name} to <b>${action.data.toList.name}</b>`;
|
||||||
|
|
||||||
|
break;
|
||||||
|
case Action.Types.COMMENT_CARD: {
|
||||||
|
html = `<b>${actorUser.name}</b> commented on ${cardLink}:\n<i>${truncateString(action.data.text)}</i>`;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await send(html);
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
inputs: {
|
inputs: {
|
||||||
values: {
|
values: {
|
||||||
@@ -116,17 +145,32 @@ module.exports = {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (sails.config.custom.slackBotToken) {
|
if (sails.config.custom.slackBotToken) {
|
||||||
buildAndSendMessage(values.card, action, values.user, sails.helpers.utils.sendSlackMessage);
|
buildAndSendMarkdownMessage(
|
||||||
|
values.card,
|
||||||
|
action,
|
||||||
|
values.user,
|
||||||
|
sails.helpers.utils.sendSlackMessage,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sails.config.custom.googleChatWebhookUrl) {
|
if (sails.config.custom.googleChatWebhookUrl) {
|
||||||
buildAndSendMessage(
|
buildAndSendMarkdownMessage(
|
||||||
values.card,
|
values.card,
|
||||||
action,
|
action,
|
||||||
values.user,
|
values.user,
|
||||||
sails.helpers.utils.sendGoogleChatMessage,
|
sails.helpers.utils.sendGoogleChatMessage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sails.config.custom.telegramBotToken) {
|
||||||
|
buildAndSendHtmlMessage(
|
||||||
|
values.card,
|
||||||
|
action,
|
||||||
|
values.user,
|
||||||
|
sails.helpers.utils.sendTelegramMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return action;
|
return action;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ module.exports = {
|
|||||||
cards: [inputs.card],
|
cards: [inputs.card],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
item: inputs.record,
|
||||||
|
},
|
||||||
user: inputs.actorUser,
|
user: inputs.actorUser,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
const path = require('path');
|
|
||||||
const rimraf = require('rimraf');
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
inputs: {
|
inputs: {
|
||||||
record: {
|
record: {
|
||||||
@@ -50,8 +47,12 @@ module.exports = {
|
|||||||
const attachment = await Attachment.archiveOne(inputs.record.id);
|
const attachment = await Attachment.archiveOne(inputs.record.id);
|
||||||
|
|
||||||
if (attachment) {
|
if (attachment) {
|
||||||
|
const fileManager = sails.hooks['file-manager'].getInstance();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
rimraf.sync(path.join(sails.config.custom.attachmentsPath, attachment.dirname));
|
await fileManager.deleteDir(
|
||||||
|
`${sails.config.custom.attachmentsPathSegment}/${attachment.dirname}`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(error.stack); // eslint-disable-line no-console
|
console.warn(error.stack); // eslint-disable-line no-console
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
const fs = require('fs');
|
const { rimraf } = require('rimraf');
|
||||||
const path = require('path');
|
|
||||||
const rimraf = require('rimraf');
|
|
||||||
const moveFile = require('move-file');
|
|
||||||
const { v4: uuid } = require('uuid');
|
const { v4: uuid } = require('uuid');
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
|
|
||||||
@@ -16,17 +13,19 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fn(inputs) {
|
async fn(inputs) {
|
||||||
const dirname = uuid();
|
const fileManager = sails.hooks['file-manager'].getInstance();
|
||||||
|
|
||||||
|
const dirname = uuid();
|
||||||
|
const dirPathSegment = `${sails.config.custom.attachmentsPathSegment}/${dirname}`;
|
||||||
const filename = filenamify(inputs.file.filename);
|
const filename = filenamify(inputs.file.filename);
|
||||||
|
|
||||||
const rootPath = path.join(sails.config.custom.attachmentsPath, dirname);
|
const filePath = await fileManager.move(
|
||||||
const filePath = path.join(rootPath, filename);
|
inputs.file.fd,
|
||||||
|
`${dirPathSegment}/${filename}`,
|
||||||
|
inputs.file.type,
|
||||||
|
);
|
||||||
|
|
||||||
fs.mkdirSync(rootPath);
|
let image = sharp(filePath || inputs.file.fd, {
|
||||||
await moveFile(inputs.file.fd, filePath);
|
|
||||||
|
|
||||||
let image = sharp(filePath, {
|
|
||||||
animated: true,
|
animated: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,9 +42,6 @@ module.exports = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (metadata && !['svg', 'pdf'].includes(metadata.format)) {
|
if (metadata && !['svg', 'pdf'].includes(metadata.format)) {
|
||||||
const thumbnailsPath = path.join(rootPath, 'thumbnails');
|
|
||||||
fs.mkdirSync(thumbnailsPath);
|
|
||||||
|
|
||||||
let { width, pageHeight: height = metadata.height } = metadata;
|
let { width, pageHeight: height = metadata.height } = metadata;
|
||||||
if (metadata.orientation && metadata.orientation > 4) {
|
if (metadata.orientation && metadata.orientation > 4) {
|
||||||
[image, width, height] = [image.rotate(), height, width];
|
[image, width, height] = [image.rotate(), height, width];
|
||||||
@@ -55,7 +51,7 @@ module.exports = {
|
|||||||
const thumbnailsExtension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
|
const thumbnailsExtension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await image
|
const resizeBuffer = await image
|
||||||
.resize(
|
.resize(
|
||||||
256,
|
256,
|
||||||
isPortrait ? 320 : undefined,
|
isPortrait ? 320 : undefined,
|
||||||
@@ -65,20 +61,30 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
.toFile(path.join(thumbnailsPath, `cover-256.${thumbnailsExtension}`));
|
.toBuffer();
|
||||||
|
|
||||||
|
await fileManager.save(
|
||||||
|
`${dirPathSegment}/thumbnails/cover-256.${thumbnailsExtension}`,
|
||||||
|
resizeBuffer,
|
||||||
|
inputs.file.type,
|
||||||
|
);
|
||||||
|
|
||||||
fileData.image = {
|
fileData.image = {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
thumbnailsExtension,
|
thumbnailsExtension,
|
||||||
};
|
};
|
||||||
} catch (error1) {
|
} catch (error) {
|
||||||
try {
|
console.warn(error.stack); // eslint-disable-line no-console
|
||||||
rimraf.sync(thumbnailsPath);
|
|
||||||
} catch (error2) {
|
|
||||||
console.warn(error2.stack); // eslint-disable-line no-console
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
try {
|
||||||
|
await rimraf(inputs.file.fd);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error.stack); // eslint-disable-line no-console
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return fileData;
|
return fileData;
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ module.exports = {
|
|||||||
cards: [inputs.card],
|
cards: [inputs.card],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
item: inputs.record,
|
||||||
|
},
|
||||||
user: inputs.actorUser,
|
user: inputs.actorUser,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ module.exports = {
|
|||||||
boards: [inputs.board],
|
boards: [inputs.board],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
item: inputs.record,
|
||||||
|
},
|
||||||
user: inputs.actorUser,
|
user: inputs.actorUser,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const fs = require('fs').promises;
|
const fs = require('fs');
|
||||||
const rimraf = require('rimraf');
|
const { rimraf } = require('rimraf');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
inputs: {
|
inputs: {
|
||||||
@@ -14,7 +14,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fn(inputs) {
|
async fn(inputs) {
|
||||||
const content = await fs.readFile(inputs.file.fd);
|
const content = await fs.promises.readFile(inputs.file.fd);
|
||||||
const trelloBoard = JSON.parse(content);
|
const trelloBoard = JSON.parse(content);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -28,7 +28,7 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
rimraf.sync(inputs.file.fd);
|
await rimraf(inputs.file.fd);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(error.stack); // eslint-disable-line no-console
|
console.warn(error.stack); // eslint-disable-line no-console
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ module.exports = {
|
|||||||
projects: [inputs.project],
|
projects: [inputs.project],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
item: inputs.record,
|
||||||
|
},
|
||||||
user: inputs.actorUser,
|
user: inputs.actorUser,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
const buildAndSendMessage = async (card, actorUser, send) => {
|
const buildAndSendMarkdownMessage = async (card, actorUser, send) => {
|
||||||
await send(`*${card.name}* was deleted by ${actorUser.name}`);
|
await send(`*${card.name}* was deleted by ${actorUser.name}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildAndSendHtmlMessage = async (card, actorUser, send) => {
|
||||||
|
await send(`<b>${card.name}</b> was deleted by ${actorUser.name}`);
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
inputs: {
|
inputs: {
|
||||||
record: {
|
record: {
|
||||||
@@ -56,11 +60,19 @@ module.exports = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (sails.config.custom.slackBotToken) {
|
if (sails.config.custom.slackBotToken) {
|
||||||
buildAndSendMessage(card, inputs.actorUser, sails.helpers.utils.sendSlackMessage);
|
buildAndSendMarkdownMessage(card, inputs.actorUser, sails.helpers.utils.sendSlackMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sails.config.custom.googleChatWebhookUrl) {
|
if (sails.config.custom.googleChatWebhookUrl) {
|
||||||
buildAndSendMessage(card, inputs.actorUser, sails.helpers.utils.sendGoogleChatMessage);
|
buildAndSendMarkdownMessage(
|
||||||
|
card,
|
||||||
|
inputs.actorUser,
|
||||||
|
sails.helpers.utils.sendGoogleChatMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sails.config.custom.telegramBotToken) {
|
||||||
|
buildAndSendHtmlMessage(card, inputs.actorUser, sails.helpers.utils.sendTelegramMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -264,6 +264,9 @@ module.exports = {
|
|||||||
lists: [list],
|
lists: [list],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
item: inputs.record,
|
||||||
|
},
|
||||||
user: inputs.actorUser,
|
user: inputs.actorUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ module.exports = {
|
|||||||
boards: [inputs.board],
|
boards: [inputs.board],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
item: inputs.record,
|
||||||
|
},
|
||||||
user: inputs.actorUser,
|
user: inputs.actorUser,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ module.exports = {
|
|||||||
boards: [inputs.board],
|
boards: [inputs.board],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
item: inputs.record,
|
||||||
|
},
|
||||||
user: inputs.actorUser,
|
user: inputs.actorUser,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ module.exports = {
|
|||||||
inputs.request,
|
inputs.request,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TODO: with prevData?
|
||||||
sails.helpers.utils.sendWebhooks.with({
|
sails.helpers.utils.sendWebhooks.with({
|
||||||
event: 'notificationUpdate',
|
event: 'notificationUpdate',
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
const fs = require('fs');
|
const { rimraf } = require('rimraf');
|
||||||
const path = require('path');
|
|
||||||
const rimraf = require('rimraf');
|
|
||||||
const { v4: uuid } = require('uuid');
|
const { v4: uuid } = require('uuid');
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
|
|
||||||
@@ -32,10 +30,10 @@ module.exports = {
|
|||||||
throw 'fileIsNotImage';
|
throw 'fileIsNotImage';
|
||||||
}
|
}
|
||||||
|
|
||||||
const dirname = uuid();
|
const fileManager = sails.hooks['file-manager'].getInstance();
|
||||||
const rootPath = path.join(sails.config.custom.projectBackgroundImagesPath, dirname);
|
|
||||||
|
|
||||||
fs.mkdirSync(rootPath);
|
const dirname = uuid();
|
||||||
|
const dirPathSegment = `${sails.config.custom.projectBackgroundImagesPathSegment}/${dirname}`;
|
||||||
|
|
||||||
let { width, pageHeight: height = metadata.height } = metadata;
|
let { width, pageHeight: height = metadata.height } = metadata;
|
||||||
if (metadata.orientation && metadata.orientation > 4) {
|
if (metadata.orientation && metadata.orientation > 4) {
|
||||||
@@ -45,9 +43,15 @@ module.exports = {
|
|||||||
const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
|
const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await image.toFile(path.join(rootPath, `original.${extension}`));
|
const originalBuffer = await image.toBuffer();
|
||||||
|
|
||||||
await image
|
await fileManager.save(
|
||||||
|
`${dirPathSegment}/original.${extension}`,
|
||||||
|
originalBuffer,
|
||||||
|
inputs.file.type,
|
||||||
|
);
|
||||||
|
|
||||||
|
const cover336Buffer = await image
|
||||||
.resize(
|
.resize(
|
||||||
336,
|
336,
|
||||||
200,
|
200,
|
||||||
@@ -57,10 +61,18 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
.toFile(path.join(rootPath, `cover-336.${extension}`));
|
.toBuffer();
|
||||||
|
|
||||||
|
await fileManager.save(
|
||||||
|
`${dirPathSegment}/cover-336.${extension}`,
|
||||||
|
cover336Buffer,
|
||||||
|
inputs.file.type,
|
||||||
|
);
|
||||||
} catch (error1) {
|
} catch (error1) {
|
||||||
|
console.warn(error1.stack); // eslint-disable-line no-console
|
||||||
|
|
||||||
try {
|
try {
|
||||||
rimraf.sync(rootPath);
|
fileManager.deleteDir(dirPathSegment);
|
||||||
} catch (error2) {
|
} catch (error2) {
|
||||||
console.warn(error2.stack); // eslint-disable-line no-console
|
console.warn(error2.stack); // eslint-disable-line no-console
|
||||||
}
|
}
|
||||||
@@ -69,7 +81,7 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
rimraf.sync(inputs.file.fd);
|
await rimraf(inputs.file.fd);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(error.stack); // eslint-disable-line no-console
|
console.warn(error.stack); // eslint-disable-line no-console
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
const path = require('path');
|
|
||||||
const rimraf = require('rimraf');
|
|
||||||
|
|
||||||
const valuesValidator = (value) => {
|
const valuesValidator = (value) => {
|
||||||
if (!_.isPlainObject(value)) {
|
if (!_.isPlainObject(value)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -86,12 +83,11 @@ module.exports = {
|
|||||||
(!project.backgroundImage ||
|
(!project.backgroundImage ||
|
||||||
project.backgroundImage.dirname !== inputs.record.backgroundImage.dirname)
|
project.backgroundImage.dirname !== inputs.record.backgroundImage.dirname)
|
||||||
) {
|
) {
|
||||||
|
const fileManager = sails.hooks['file-manager'].getInstance();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
rimraf.sync(
|
await fileManager.deleteDir(
|
||||||
path.join(
|
`${sails.config.custom.projectBackgroundImagesPathSegment}/${inputs.record.backgroundImage.dirname}`,
|
||||||
sails.config.custom.projectBackgroundImagesPath,
|
|
||||||
inputs.record.backgroundImage.dirname,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(error.stack); // eslint-disable-line no-console
|
console.warn(error.stack); // eslint-disable-line no-console
|
||||||
@@ -118,6 +114,9 @@ module.exports = {
|
|||||||
data: {
|
data: {
|
||||||
item: project,
|
item: project,
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
item: inputs.record,
|
||||||
|
},
|
||||||
user: inputs.actorUser,
|
user: inputs.actorUser,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ module.exports = {
|
|||||||
cards: [inputs.card],
|
cards: [inputs.card],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
item: inputs.record,
|
||||||
|
},
|
||||||
user: inputs.actorUser,
|
user: inputs.actorUser,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
const fs = require('fs');
|
const { rimraf } = require('rimraf');
|
||||||
const path = require('path');
|
|
||||||
const rimraf = require('rimraf');
|
|
||||||
const { v4: uuid } = require('uuid');
|
const { v4: uuid } = require('uuid');
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
|
|
||||||
@@ -32,10 +30,10 @@ module.exports = {
|
|||||||
throw 'fileIsNotImage';
|
throw 'fileIsNotImage';
|
||||||
}
|
}
|
||||||
|
|
||||||
const dirname = uuid();
|
const fileManager = sails.hooks['file-manager'].getInstance();
|
||||||
const rootPath = path.join(sails.config.custom.userAvatarsPath, dirname);
|
|
||||||
|
|
||||||
fs.mkdirSync(rootPath);
|
const dirname = uuid();
|
||||||
|
const dirPathSegment = `${sails.config.custom.userAvatarsPathSegment}/${dirname}`;
|
||||||
|
|
||||||
let { width, pageHeight: height = metadata.height } = metadata;
|
let { width, pageHeight: height = metadata.height } = metadata;
|
||||||
if (metadata.orientation && metadata.orientation > 4) {
|
if (metadata.orientation && metadata.orientation > 4) {
|
||||||
@@ -45,9 +43,15 @@ module.exports = {
|
|||||||
const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
|
const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await image.toFile(path.join(rootPath, `original.${extension}`));
|
const originalBuffer = await image.toBuffer();
|
||||||
|
|
||||||
await image
|
await fileManager.save(
|
||||||
|
`${dirPathSegment}/original.${extension}`,
|
||||||
|
originalBuffer,
|
||||||
|
inputs.file.type,
|
||||||
|
);
|
||||||
|
|
||||||
|
const square100Buffer = await image
|
||||||
.resize(
|
.resize(
|
||||||
100,
|
100,
|
||||||
100,
|
100,
|
||||||
@@ -57,10 +61,18 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
.toFile(path.join(rootPath, `square-100.${extension}`));
|
.toBuffer();
|
||||||
|
|
||||||
|
await fileManager.save(
|
||||||
|
`${dirPathSegment}/square-100.${extension}`,
|
||||||
|
square100Buffer,
|
||||||
|
inputs.file.type,
|
||||||
|
);
|
||||||
} catch (error1) {
|
} catch (error1) {
|
||||||
|
console.warn(error1.stack); // eslint-disable-line no-console
|
||||||
|
|
||||||
try {
|
try {
|
||||||
rimraf.sync(rootPath);
|
fileManager.deleteDir(dirPathSegment);
|
||||||
} catch (error2) {
|
} catch (error2) {
|
||||||
console.warn(error2.stack); // eslint-disable-line no-console
|
console.warn(error2.stack); // eslint-disable-line no-console
|
||||||
}
|
}
|
||||||
@@ -69,7 +81,7 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
rimraf.sync(inputs.file.fd);
|
await rimraf(inputs.file.fd);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(error.stack); // eslint-disable-line no-console
|
console.warn(error.stack); // eslint-disable-line no-console
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
const path = require('path');
|
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const rimraf = require('rimraf');
|
|
||||||
const { v4: uuid } = require('uuid');
|
const { v4: uuid } = require('uuid');
|
||||||
|
|
||||||
const valuesValidator = (value) => {
|
const valuesValidator = (value) => {
|
||||||
@@ -101,8 +99,12 @@ module.exports = {
|
|||||||
inputs.record.avatar &&
|
inputs.record.avatar &&
|
||||||
(!user.avatar || user.avatar.dirname !== inputs.record.avatar.dirname)
|
(!user.avatar || user.avatar.dirname !== inputs.record.avatar.dirname)
|
||||||
) {
|
) {
|
||||||
|
const fileManager = sails.hooks['file-manager'].getInstance();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
rimraf.sync(path.join(sails.config.custom.userAvatarsPath, inputs.record.avatar.dirname));
|
await fileManager.deleteDir(
|
||||||
|
`${sails.config.custom.userAvatarsPathSegment}/${inputs.record.avatar.dirname}`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(error.stack); // eslint-disable-line no-console
|
console.warn(error.stack); // eslint-disable-line no-console
|
||||||
}
|
}
|
||||||
@@ -159,6 +161,9 @@ module.exports = {
|
|||||||
data: {
|
data: {
|
||||||
item: user,
|
item: user,
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
item: inputs.record,
|
||||||
|
},
|
||||||
user: inputs.actorUser,
|
user: inputs.actorUser,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const { v4: uuid } = require('uuid');
|
|||||||
async function doUpload(paramName, req, options) {
|
async function doUpload(paramName, req, options) {
|
||||||
const uploadOptions = {
|
const uploadOptions = {
|
||||||
...options,
|
...options,
|
||||||
dirname: options.dirname || sails.config.custom.fileUploadTmpDir,
|
dirname: options.dirname || sails.config.custom.uploadsTempPath,
|
||||||
};
|
};
|
||||||
const upload = util.promisify((opts, callback) => {
|
const upload = util.promisify((opts, callback) => {
|
||||||
return req.file(paramName).upload(opts, (error, files) => callback(error, files));
|
return req.file(paramName).upload(opts, (error, files) => callback(error, files));
|
||||||
@@ -33,7 +33,7 @@ module.exports = {
|
|||||||
exits.success(
|
exits.success(
|
||||||
await doUpload(inputs.paramName, inputs.req, {
|
await doUpload(inputs.paramName, inputs.req, {
|
||||||
saveAs: uuid(),
|
saveAs: uuid(),
|
||||||
dirname: sails.config.custom.fileUploadTmpDir,
|
dirname: sails.config.custom.uploadsTempPath,
|
||||||
maxBytes: null,
|
maxBytes: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
44
server/api/helpers/utils/send-telegram-message.js
Normal file
44
server/api/helpers/utils/send-telegram-message.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
const buildSendMessageApiUrl = (telegramBotToken) =>
|
||||||
|
`https://api.telegram.org/bot${telegramBotToken}/sendMessage`;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
inputs: {
|
||||||
|
html: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async fn(inputs) {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
chat_id: sails.config.custom.telegramChatId,
|
||||||
|
text: inputs.html,
|
||||||
|
parse_mode: 'HTML',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sails.config.custom.telegramThreadId) {
|
||||||
|
body.message_thread_id = sails.config.custom.telegramThreadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(buildSendMessageApiUrl(sails.config.custom.telegramBotToken), {
|
||||||
|
headers,
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
sails.log.error(`Error sending to Telegram: ${error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const responseJson = await response.json();
|
||||||
|
sails.log.error(`Error sending to Telegram: ${responseJson.description}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -97,10 +97,11 @@ const jsonifyData = (data) => {
|
|||||||
* @param {*} webhook - Webhook configuration.
|
* @param {*} webhook - Webhook configuration.
|
||||||
* @param {string} event - The event (see {@link EVENT_TYPES}).
|
* @param {string} event - The event (see {@link EVENT_TYPES}).
|
||||||
* @param {Data} data - The data object containing event data and optionally included data.
|
* @param {Data} data - The data object containing event data and optionally included data.
|
||||||
|
* @param {Data} [prevData] - The data object containing previous state of data (optional).
|
||||||
* @param {ref} user - User object associated with the event.
|
* @param {ref} user - User object associated with the event.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async function sendWebhook(webhook, event, data, user) {
|
async function sendWebhook(webhook, event, data, prevData, user) {
|
||||||
const headers = {
|
const headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'User-Agent': `planka (+${sails.config.custom.baseUrl})`,
|
'User-Agent': `planka (+${sails.config.custom.baseUrl})`,
|
||||||
@@ -113,6 +114,7 @@ async function sendWebhook(webhook, event, data, user) {
|
|||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
event,
|
event,
|
||||||
data: jsonifyData(data),
|
data: jsonifyData(data),
|
||||||
|
prevData: prevData && jsonifyData(prevData),
|
||||||
user: sails.helpers.utils.jsonifyRecord(user),
|
user: sails.helpers.utils.jsonifyRecord(user),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,6 +150,9 @@ module.exports = {
|
|||||||
type: 'ref',
|
type: 'ref',
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
type: 'ref',
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
type: 'ref',
|
type: 'ref',
|
||||||
required: true,
|
required: true,
|
||||||
@@ -172,7 +177,7 @@ module.exports = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendWebhook(webhook, inputs.event, inputs.data, inputs.user);
|
sendWebhook(webhook, inputs.event, inputs.data, inputs.prevData, inputs.user);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
51
server/api/hooks/file-manager/LocalFileManager.js
Normal file
51
server/api/hooks/file-manager/LocalFileManager.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const fse = require('fs-extra');
|
||||||
|
const path = require('path');
|
||||||
|
const { rimraf } = require('rimraf');
|
||||||
|
|
||||||
|
const PATH_SEGMENT_TO_URL_REPLACE_REGEX = /(public|private)\//;
|
||||||
|
|
||||||
|
const buildPath = (pathSegment) => path.join(sails.config.custom.uploadsBasePath, pathSegment);
|
||||||
|
|
||||||
|
class LocalFileManager {
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
async move(sourceFilePath, filePathSegment) {
|
||||||
|
const { dir, base } = path.parse(filePathSegment);
|
||||||
|
|
||||||
|
const dirPath = buildPath(dir);
|
||||||
|
const filePath = path.join(dirPath, base);
|
||||||
|
|
||||||
|
await fs.promises.mkdir(dirPath);
|
||||||
|
await fse.move(sourceFilePath, filePath);
|
||||||
|
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
async save(filePathSegment, buffer) {
|
||||||
|
await fse.outputFile(buildPath(filePathSegment), buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
read(filePathSegment) {
|
||||||
|
const filePath = buildPath(filePathSegment);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
throw new Error('File does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs.createReadStream(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
async deleteDir(dirPathSegment) {
|
||||||
|
await rimraf(buildPath(dirPathSegment));
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
buildUrl(filePathSegment) {
|
||||||
|
return `${sails.config.custom.baseUrl}/${filePathSegment.replace(PATH_SEGMENT_TO_URL_REPLACE_REGEX, '')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = LocalFileManager;
|
||||||
76
server/api/hooks/file-manager/S3FileManager.js
Normal file
76
server/api/hooks/file-manager/S3FileManager.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const {
|
||||||
|
DeleteObjectsCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
ListObjectsV2Command,
|
||||||
|
PutObjectCommand,
|
||||||
|
} = require('@aws-sdk/client-s3');
|
||||||
|
|
||||||
|
class S3FileManager {
|
||||||
|
constructor(client) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async move(sourceFilePath, filePathSegment, contentType) {
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: sails.config.custom.s3Bucket,
|
||||||
|
Key: filePathSegment,
|
||||||
|
Body: fs.createReadStream(sourceFilePath),
|
||||||
|
ContentType: contentType,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.client.send(command);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(filePathSegment, buffer, contentType) {
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: sails.config.custom.s3Bucket,
|
||||||
|
Key: filePathSegment,
|
||||||
|
Body: buffer,
|
||||||
|
ContentType: contentType,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.client.send(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(filePathSegment) {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: sails.config.custom.s3Bucket,
|
||||||
|
Key: filePathSegment,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await this.client.send(command);
|
||||||
|
return result.Body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDir(dirPathSegment) {
|
||||||
|
const listObjectsCommand = new ListObjectsV2Command({
|
||||||
|
Bucket: sails.config.custom.s3Bucket,
|
||||||
|
Prefix: dirPathSegment,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await this.client.send(listObjectsCommand);
|
||||||
|
|
||||||
|
if (!result.Contents || result.Contents.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteObjectsCommand = new DeleteObjectsCommand({
|
||||||
|
Bucket: sails.config.custom.s3Bucket,
|
||||||
|
Delete: {
|
||||||
|
Objects: result.Contents.map(({ Key }) => ({ Key })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.client.send(deleteObjectsCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
buildUrl(filePathSegment) {
|
||||||
|
return `${sails.hooks.s3.getBaseUrl()}/${filePathSegment}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = S3FileManager;
|
||||||
41
server/api/hooks/file-manager/index.js
Normal file
41
server/api/hooks/file-manager/index.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
const LocalFileManager = require('./LocalFileManager');
|
||||||
|
const S3FileManager = require('./S3FileManager');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* file-manager hook
|
||||||
|
*
|
||||||
|
* @description :: A hook definition. Extends Sails by adding shadow routes, implicit actions,
|
||||||
|
* and/or initialization logic.
|
||||||
|
* @docs :: https://sailsjs.com/docs/concepts/extending-sails/hooks
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = function defineFileManagerHook(sails) {
|
||||||
|
let instance = null;
|
||||||
|
|
||||||
|
const createInstance = () => {
|
||||||
|
instance = sails.hooks.s3.isActive()
|
||||||
|
? new S3FileManager(sails.hooks.s3.getClient())
|
||||||
|
: new LocalFileManager();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Runs when this Sails app loads/lifts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
sails.log.info('Initializing custom hook (`file-manager`)');
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
sails.after('hook:s3:loaded', () => {
|
||||||
|
createInstance();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getInstance() {
|
||||||
|
return instance;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
64
server/api/hooks/s3/index.js
Normal file
64
server/api/hooks/s3/index.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
const { URL } = require('url');
|
||||||
|
const { S3Client } = require('@aws-sdk/client-s3');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* s3 hook
|
||||||
|
*
|
||||||
|
* @description :: A hook definition. Extends Sails by adding shadow routes, implicit actions,
|
||||||
|
* and/or initialization logic.
|
||||||
|
* @docs :: https://sailsjs.com/docs/concepts/extending-sails/hooks
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = function defineS3Hook(sails) {
|
||||||
|
let client = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Runs when this Sails app loads/lifts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
if (!sails.config.custom.s3Endpoint && !sails.config.custom.s3Region) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sails.log.info('Initializing custom hook (`s3`)');
|
||||||
|
|
||||||
|
client = new S3Client({
|
||||||
|
endpoint: sails.config.custom.s3Endpoint,
|
||||||
|
region: sails.config.custom.s3Region || '-',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: sails.config.custom.s3AccessKeyId,
|
||||||
|
secretAccessKey: sails.config.custom.s3SecretAccessKey,
|
||||||
|
},
|
||||||
|
forcePathStyle: sails.config.custom.s3ForcePathStyle,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getClient() {
|
||||||
|
return client;
|
||||||
|
},
|
||||||
|
|
||||||
|
getBaseUrl() {
|
||||||
|
if (sails.config.custom.s3Endpoint) {
|
||||||
|
const { protocol, host } = new URL(sails.config.custom.s3Endpoint);
|
||||||
|
|
||||||
|
if (sails.config.custom.s3ForcePathStyle) {
|
||||||
|
return `${protocol}//${host}/${sails.config.custom.s3Bucket}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${protocol}//${sails.config.custom.s3Bucket}.${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sails.config.custom.s3ForcePathStyle) {
|
||||||
|
return `https://s3.${sails.config.custom.s3Region}.amazonaws.com/${sails.config.custom.s3Bucket}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `https://${sails.config.custom.s3Bucket}.s3.${sails.config.custom.s3Region}.amazonaws.com`;
|
||||||
|
},
|
||||||
|
|
||||||
|
isActive() {
|
||||||
|
return client !== null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -50,9 +50,9 @@ module.exports = {
|
|||||||
customToJSON() {
|
customToJSON() {
|
||||||
return {
|
return {
|
||||||
..._.omit(this, ['dirname', 'filename', 'image.thumbnailsExtension']),
|
..._.omit(this, ['dirname', 'filename', 'image.thumbnailsExtension']),
|
||||||
url: `${sails.config.custom.attachmentsUrl}/${this.id}/download/${this.filename}`,
|
url: `${sails.config.custom.baseUrl}/attachments/${this.id}/download/${this.filename}`,
|
||||||
coverUrl: this.image
|
coverUrl: this.image
|
||||||
? `${sails.config.custom.attachmentsUrl}/${this.id}/download/thumbnails/cover-256.${this.image.thumbnailsExtension}`
|
? `${sails.config.custom.baseUrl}/attachments/${this.id}/download/thumbnails/cover-256.${this.image.thumbnailsExtension}`
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -79,11 +79,13 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
customToJSON() {
|
customToJSON() {
|
||||||
|
const fileManager = sails.hooks['file-manager'].getInstance();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
..._.omit(this, ['backgroundImage']),
|
..._.omit(this, ['backgroundImage']),
|
||||||
backgroundImage: this.backgroundImage && {
|
backgroundImage: this.backgroundImage && {
|
||||||
url: `${sails.config.custom.projectBackgroundImagesUrl}/${this.backgroundImage.dirname}/original.${this.backgroundImage.extension}`,
|
url: `${fileManager.buildUrl(`${sails.config.custom.projectBackgroundImagesPathSegment}/${this.backgroundImage.dirname}/original.${this.backgroundImage.extension}`)}`,
|
||||||
coverUrl: `${sails.config.custom.projectBackgroundImagesUrl}/${this.backgroundImage.dirname}/cover-336.${this.backgroundImage.extension}`,
|
coverUrl: `${fileManager.buildUrl(`${sails.config.custom.projectBackgroundImagesPathSegment}/${this.backgroundImage.dirname}/cover-336.${this.backgroundImage.extension}`)}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ module.exports = {
|
|||||||
tableName: 'user_account',
|
tableName: 'user_account',
|
||||||
|
|
||||||
customToJSON() {
|
customToJSON() {
|
||||||
|
const fileManager = sails.hooks['file-manager'].getInstance();
|
||||||
const isDefaultAdmin = this.email === sails.config.custom.defaultAdminEmail;
|
const isDefaultAdmin = this.email === sails.config.custom.defaultAdminEmail;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -157,7 +158,7 @@ module.exports = {
|
|||||||
isDeletionLocked: isDefaultAdmin,
|
isDeletionLocked: isDefaultAdmin,
|
||||||
avatarUrl:
|
avatarUrl:
|
||||||
this.avatar &&
|
this.avatar &&
|
||||||
`${sails.config.custom.userAvatarsUrl}/${this.avatar.dirname}/square-100.${this.avatar.extension}`,
|
`${fileManager.buildUrl(`${sails.config.custom.userAvatarsPathSegment}/${this.avatar.dirname}/square-100.${this.avatar.extension}`)}`,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,11 +8,10 @@
|
|||||||
* https://sailsjs.com/config/custom
|
* https://sailsjs.com/config/custom
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const url = require('url');
|
const { URL } = require('url');
|
||||||
const path = require('path');
|
|
||||||
const sails = require('sails');
|
const sails = require('sails');
|
||||||
|
|
||||||
const parsedBasedUrl = new url.URL(process.env.BASE_URL);
|
const parsedBasedUrl = new URL(process.env.BASE_URL);
|
||||||
|
|
||||||
module.exports.custom = {
|
module.exports.custom = {
|
||||||
/**
|
/**
|
||||||
@@ -28,24 +27,26 @@ module.exports.custom = {
|
|||||||
tokenExpiresIn: parseInt(process.env.TOKEN_EXPIRES_IN, 10) || 365,
|
tokenExpiresIn: parseInt(process.env.TOKEN_EXPIRES_IN, 10) || 365,
|
||||||
|
|
||||||
// Location to receive uploaded files in. Default (non-string value) is a Sails-specific location.
|
// Location to receive uploaded files in. Default (non-string value) is a Sails-specific location.
|
||||||
fileUploadTmpDir: null,
|
uploadsTempPath: null,
|
||||||
|
uploadsBasePath: sails.config.appPath,
|
||||||
|
|
||||||
userAvatarsPath: path.join(sails.config.paths.public, 'user-avatars'),
|
userAvatarsPathSegment: 'public/user-avatars',
|
||||||
userAvatarsUrl: `${process.env.BASE_URL}/user-avatars`,
|
projectBackgroundImagesPathSegment: 'public/project-background-images',
|
||||||
|
attachmentsPathSegment: 'private/attachments',
|
||||||
projectBackgroundImagesPath: path.join(sails.config.paths.public, 'project-background-images'),
|
|
||||||
projectBackgroundImagesUrl: `${process.env.BASE_URL}/project-background-images`,
|
|
||||||
|
|
||||||
attachmentsPath: path.join(sails.config.appPath, 'private', 'attachments'),
|
|
||||||
attachmentsUrl: `${process.env.BASE_URL}/attachments`,
|
|
||||||
|
|
||||||
defaultAdminEmail:
|
defaultAdminEmail:
|
||||||
process.env.DEFAULT_ADMIN_EMAIL && process.env.DEFAULT_ADMIN_EMAIL.toLowerCase(),
|
process.env.DEFAULT_ADMIN_EMAIL && process.env.DEFAULT_ADMIN_EMAIL.toLowerCase(),
|
||||||
|
|
||||||
showDetailedAuthErrors: process.env.SHOW_DETAILED_AUTH_ERRORS === 'true',
|
showDetailedAuthErrors: process.env.SHOW_DETAILED_AUTH_ERRORS === 'true',
|
||||||
|
|
||||||
allowAllToCreateProjects: process.env.ALLOW_ALL_TO_CREATE_PROJECTS === 'true',
|
allowAllToCreateProjects: process.env.ALLOW_ALL_TO_CREATE_PROJECTS === 'true',
|
||||||
|
|
||||||
|
s3Endpoint: process.env.S3_ENDPOINT,
|
||||||
|
s3Region: process.env.S3_REGION,
|
||||||
|
s3AccessKeyId: process.env.S3_ACCESS_KEY_ID,
|
||||||
|
s3SecretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||||||
|
s3Bucket: process.env.S3_BUCKET,
|
||||||
|
s3ForcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true',
|
||||||
|
|
||||||
oidcIssuer: process.env.OIDC_ISSUER,
|
oidcIssuer: process.env.OIDC_ISSUER,
|
||||||
oidcClientId: process.env.OIDC_CLIENT_ID,
|
oidcClientId: process.env.OIDC_CLIENT_ID,
|
||||||
oidcClientSecret: process.env.OIDC_CLIENT_SECRET,
|
oidcClientSecret: process.env.OIDC_CLIENT_SECRET,
|
||||||
@@ -82,5 +83,10 @@ module.exports.custom = {
|
|||||||
|
|
||||||
slackBotToken: process.env.SLACK_BOT_TOKEN,
|
slackBotToken: process.env.SLACK_BOT_TOKEN,
|
||||||
slackChannelId: process.env.SLACK_CHANNEL_ID,
|
slackChannelId: process.env.SLACK_CHANNEL_ID,
|
||||||
|
|
||||||
googleChatWebhookUrl: process.env.GOOGLE_CHAT_WEBHOOK_URL,
|
googleChatWebhookUrl: process.env.GOOGLE_CHAT_WEBHOOK_URL,
|
||||||
|
|
||||||
|
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN,
|
||||||
|
telegramChatId: process.env.TELEGRAM_CHAT_ID,
|
||||||
|
telegramThreadId: process.env.TELEGRAM_THREAD_ID,
|
||||||
};
|
};
|
||||||
|
|||||||
4
server/config/env/production.js
vendored
4
server/config/env/production.js
vendored
@@ -19,11 +19,11 @@
|
|||||||
* https://sailsjs.com/docs/concepts/deployment
|
* https://sailsjs.com/docs/concepts/deployment
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const url = require('url');
|
const { URL } = require('url');
|
||||||
|
|
||||||
const { customLogger } = require('../../utils/logger');
|
const { customLogger } = require('../../utils/logger');
|
||||||
|
|
||||||
const parsedBasedUrl = new url.URL(process.env.BASE_URL);
|
const parsedBasedUrl = new URL(process.env.BASE_URL);
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -135,13 +135,21 @@ module.exports.routes = {
|
|||||||
'PATCH /api/notifications/:ids': 'notifications/update',
|
'PATCH /api/notifications/:ids': 'notifications/update',
|
||||||
|
|
||||||
'GET /user-avatars/*': {
|
'GET /user-avatars/*': {
|
||||||
fn: staticDirServer('/user-avatars', () => path.resolve(sails.config.custom.userAvatarsPath)),
|
fn: staticDirServer('/user-avatars', () =>
|
||||||
|
path.join(
|
||||||
|
path.resolve(sails.config.custom.uploadsBasePath),
|
||||||
|
sails.config.custom.userAvatarsPathSegment,
|
||||||
|
),
|
||||||
|
),
|
||||||
skipAssets: false,
|
skipAssets: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
'GET /project-background-images/*': {
|
'GET /project-background-images/*': {
|
||||||
fn: staticDirServer('/project-background-images', () =>
|
fn: staticDirServer('/project-background-images', () =>
|
||||||
path.resolve(sails.config.custom.projectBackgroundImagesPath),
|
path.join(
|
||||||
|
path.resolve(sails.config.custom.uploadsBasePath),
|
||||||
|
sails.config.custom.projectBackgroundImagesPathSegment,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
skipAssets: false,
|
skipAssets: false,
|
||||||
},
|
},
|
||||||
|
|||||||
1835
server/package-lock.json
generated
1835
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@
|
|||||||
"db:migrate": "knex migrate:latest --cwd db",
|
"db:migrate": "knex migrate:latest --cwd db",
|
||||||
"db:seed": "knex seed:run --cwd db",
|
"db:seed": "knex seed:run --cwd db",
|
||||||
"lint": "eslint . --max-warnings=0 --report-unused-disable-directives && echo '✔ Your .js files look good.'",
|
"lint": "eslint . --max-warnings=0 --report-unused-disable-directives && echo '✔ Your .js files look good.'",
|
||||||
"preinstall": "npx npm-force-resolutions",
|
|
||||||
"start": "nodemon",
|
"start": "nodemon",
|
||||||
"start:prod": "node app.js --prod",
|
"start:prod": "node app.js --prod",
|
||||||
"test": "mocha test/lifecycle.test.js test/integration/**/*.test.js test/utils/**/*.test.js"
|
"test": "mocha test/lifecycle.test.js test/integration/**/*.test.js test/utils/**/*.test.js"
|
||||||
@@ -28,18 +27,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.688.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"dotenv-cli": "^7.4.2",
|
"dotenv-cli": "^7.4.2",
|
||||||
|
"fs-extra": "^11.2.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"knex": "^3.1.0",
|
"knex": "^3.1.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"move-file": "^2.1.0",
|
|
||||||
"nodemailer": "^6.9.15",
|
"nodemailer": "^6.9.15",
|
||||||
"openid-client": "^5.7.0",
|
"openid-client": "^5.7.0",
|
||||||
"rimraf": "^5.0.10",
|
"rimraf": "^5.0.10",
|
||||||
"sails": "^1.5.11",
|
"sails": "^1.5.12",
|
||||||
"sails-hook-orm": "^4.0.3",
|
"sails-hook-orm": "^4.0.3",
|
||||||
"sails-hook-sockets": "^3.0.1",
|
"sails-hook-sockets": "^3.0.1",
|
||||||
"sails-postgresql": "^5.0.1",
|
"sails-postgresql": "^5.0.1",
|
||||||
@@ -59,11 +59,6 @@
|
|||||||
"mocha": "^10.7.3",
|
"mocha": "^10.7.3",
|
||||||
"nodemon": "^3.1.4"
|
"nodemon": "^3.1.4"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
|
||||||
"body-parser": "1.20.3",
|
|
||||||
"express": "4.21.0",
|
|
||||||
"send": "0.19.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user