Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9aa92da633 | ||
|
|
4a3c172992 | ||
|
|
5d1ad8a183 | ||
|
|
f95a07d8c8 | ||
|
|
0af75897f5 | ||
|
|
3602ff6930 | ||
|
|
6d1275d58c | ||
|
|
cf7b288f93 | ||
|
|
0fe7da6265 | ||
|
|
8801ab0081 | ||
|
|
c2464939fc | ||
|
|
34e231230f | ||
|
|
eb5ac2fc2d | ||
|
|
b6cf3a5a8b | ||
|
|
bbc465a3b2 | ||
|
|
9a77e383cd | ||
|
|
a2d9e70a83 | ||
|
|
c7763dd431 | ||
|
|
599ff2eec4 | ||
|
|
568a1bb228 | ||
|
|
935e4b5c33 | ||
|
|
841388e437 | ||
|
|
9ecea15d74 | ||
|
|
4ad88c969c | ||
|
|
706085395e | ||
|
|
a0f7acae42 | ||
|
|
6e5549c439 | ||
|
|
cf8eeee03a | ||
|
|
d3b26996dd | ||
|
|
d0f26f8734 | ||
|
|
e86b40ac82 | ||
|
|
c209a713d8 | ||
|
|
f10cd92610 | ||
|
|
03479cf6b3 | ||
|
|
b8a88fe89c | ||
|
|
409ac30c91 | ||
|
|
a59ebadd34 | ||
|
|
174ebfad56 | ||
|
|
008e048968 | ||
|
|
7e15fcc080 | ||
|
|
6636b8882f | ||
|
|
6398fc3cba | ||
|
|
fc698bd9b4 | ||
|
|
cd61e8dd44 | ||
|
|
cbcdd86569 | ||
|
|
553f01fde1 | ||
|
|
d8f58d28ed | ||
|
|
b194089fec | ||
|
|
927da438c7 | ||
|
|
9c21fd320c | ||
|
|
f142266bed | ||
|
|
4b06bc4d2d | ||
|
|
d692db47b2 | ||
|
|
3391e8580c |
@@ -16,3 +16,48 @@ out/
|
||||
**/out/
|
||||
dist/
|
||||
**/dist/
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
.pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# OS junk
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor settings
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# Coverage and test output
|
||||
coverage/
|
||||
**/coverage/
|
||||
*.lcov
|
||||
.junit/
|
||||
test-results/
|
||||
|
||||
# Caches and build artifacts
|
||||
.cache/
|
||||
**/.cache/
|
||||
storybook-static/
|
||||
*storybook.log
|
||||
*.tsbuildinfo
|
||||
|
||||
# Local env and secrets
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.secrets
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Database/cache dumps
|
||||
*.rdb
|
||||
*.rdb.gz
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
@@ -35,6 +35,10 @@ on:
|
||||
- preview
|
||||
- canary
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
TARGET_BRANCH: ${{ github.ref_name }}
|
||||
ARM64_BUILD: ${{ github.event.inputs.arm64 }}
|
||||
@@ -135,7 +139,8 @@ jobs:
|
||||
branch_build_push_admin:
|
||||
name: Build-Push Admin Docker Image
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
needs:
|
||||
- branch_build_setup
|
||||
steps:
|
||||
- name: Admin Build and Push
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
@@ -148,7 +153,9 @@ jobs:
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_admin }}
|
||||
build-context: .
|
||||
dockerfile-path: ./apps/admin/Dockerfile.admin
|
||||
dockerfile-path: ./Dockerfile.node
|
||||
build-args: |
|
||||
APP_SCOPE=admin
|
||||
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
@@ -157,7 +164,8 @@ jobs:
|
||||
branch_build_push_web:
|
||||
name: Build-Push Web Docker Image
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
needs:
|
||||
- branch_build_setup
|
||||
steps:
|
||||
- name: Web Build and Push
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
@@ -170,7 +178,9 @@ jobs:
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_web }}
|
||||
build-context: .
|
||||
dockerfile-path: ./apps/web/Dockerfile.web
|
||||
dockerfile-path: ./Dockerfile.node
|
||||
build-args: |
|
||||
APP_SCOPE=web
|
||||
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
@@ -179,7 +189,8 @@ jobs:
|
||||
branch_build_push_space:
|
||||
name: Build-Push Space Docker Image
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
needs:
|
||||
- branch_build_setup
|
||||
steps:
|
||||
- name: Space Build and Push
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
@@ -192,7 +203,9 @@ jobs:
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_space }}
|
||||
build-context: .
|
||||
dockerfile-path: ./apps/space/Dockerfile.space
|
||||
dockerfile-path: ./Dockerfile.node
|
||||
build-args: |
|
||||
APP_SCOPE=space
|
||||
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
@@ -201,7 +214,8 @@ jobs:
|
||||
branch_build_push_live:
|
||||
name: Build-Push Live Collaboration Docker Image
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
needs:
|
||||
- branch_build_setup
|
||||
steps:
|
||||
- name: Live Build and Push
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
@@ -223,7 +237,8 @@ jobs:
|
||||
branch_build_push_api:
|
||||
name: Build-Push API Server Docker Image
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
needs:
|
||||
- branch_build_setup
|
||||
steps:
|
||||
- name: Backend Build and Push
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
@@ -235,8 +250,8 @@ jobs:
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_backend }}
|
||||
build-context: ./apps/api
|
||||
dockerfile-path: ./apps/api/Dockerfile.api
|
||||
build-context: .
|
||||
dockerfile-path: ./Dockerfile.api
|
||||
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
@@ -245,7 +260,8 @@ jobs:
|
||||
branch_build_push_proxy:
|
||||
name: Build-Push Proxy Docker Image
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
needs:
|
||||
- branch_build_setup
|
||||
steps:
|
||||
- name: Proxy Build and Push
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
@@ -268,15 +284,14 @@ jobs:
|
||||
if: ${{ needs.branch_build_setup.outputs.aio_build == 'true' }}
|
||||
name: Build-Push AIO Docker Image
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [
|
||||
branch_build_setup,
|
||||
branch_build_push_admin,
|
||||
branch_build_push_web,
|
||||
branch_build_push_space,
|
||||
branch_build_push_live,
|
||||
branch_build_push_api,
|
||||
branch_build_push_proxy
|
||||
]
|
||||
needs:
|
||||
- branch_build_setup
|
||||
- branch_build_push_admin
|
||||
- branch_build_push_web
|
||||
- branch_build_push_space
|
||||
- branch_build_push_live
|
||||
- branch_build_push_api
|
||||
- branch_build_push_proxy
|
||||
steps:
|
||||
- name: Checkout Files
|
||||
uses: actions/checkout@v4
|
||||
@@ -285,7 +300,7 @@ jobs:
|
||||
id: prepare_aio_assets
|
||||
run: |
|
||||
cd deployments/aio/community
|
||||
|
||||
|
||||
if [ "${{ needs.branch_build_setup.outputs.build_type }}" == "Release" ]; then
|
||||
aio_version=${{ needs.branch_build_setup.outputs.release_version }}
|
||||
else
|
||||
@@ -324,7 +339,14 @@ jobs:
|
||||
upload_build_assets:
|
||||
name: Upload Build Assets
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup, branch_build_push_admin, branch_build_push_web, branch_build_push_space, branch_build_push_live, branch_build_push_api, branch_build_push_proxy]
|
||||
needs:
|
||||
- branch_build_setup
|
||||
- branch_build_push_admin
|
||||
- branch_build_push_web
|
||||
- branch_build_push_space
|
||||
- branch_build_push_live
|
||||
- branch_build_push_api
|
||||
- branch_build_push_proxy
|
||||
steps:
|
||||
- name: Checkout Files
|
||||
uses: actions/checkout@v4
|
||||
@@ -358,15 +380,13 @@ jobs:
|
||||
name: Build Release
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
[
|
||||
branch_build_setup,
|
||||
branch_build_push_admin,
|
||||
branch_build_push_web,
|
||||
branch_build_push_space,
|
||||
branch_build_push_live,
|
||||
branch_build_push_api,
|
||||
branch_build_push_proxy,
|
||||
]
|
||||
- branch_build_setup
|
||||
- branch_build_push_admin
|
||||
- branch_build_push_web
|
||||
- branch_build_push_space
|
||||
- branch_build_push_live
|
||||
- branch_build_push_api
|
||||
- branch_build_push_proxy
|
||||
env:
|
||||
REL_VERSION: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
steps:
|
||||
@@ -397,4 +417,3 @@ jobs:
|
||||
${{ github.workspace }}/deployments/cli/community/docker-compose.yml
|
||||
${{ github.workspace }}/deployments/cli/community/variables.env
|
||||
${{ github.workspace }}/deployments/swarm/community/swarm.sh
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
name: Docker AIO build and smoke test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- "preview"
|
||||
paths:
|
||||
- "apps/web/**"
|
||||
- "apps/space/**"
|
||||
- "apps/admin/**"
|
||||
- "apps/live/**"
|
||||
- "packages/**"
|
||||
- "turbo.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "Dockerfile.node"
|
||||
- "Dockerfile.api"
|
||||
- "Dockerfile.aio"
|
||||
- "docker-bake.hcl"
|
||||
- ".github/workflows/docker-smoke-aio.yml"
|
||||
push:
|
||||
branches:
|
||||
- "preview"
|
||||
paths:
|
||||
- "apps/web/**"
|
||||
- "apps/space/**"
|
||||
- "apps/admin/**"
|
||||
- "apps/live/**"
|
||||
- "packages/**"
|
||||
- "turbo.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "Dockerfile.node"
|
||||
- "Dockerfile.api"
|
||||
- "Dockerfile.aio"
|
||||
- "docker-bake.hcl"
|
||||
- ".github/workflows/docker-smoke-aio.yml"
|
||||
|
||||
concurrency:
|
||||
group: aio-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
determine-aio:
|
||||
name: Determine if AIO needed
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
aio_needed: ${{ steps.build-flag.outputs.aio_needed }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect changed paths
|
||||
id: changes
|
||||
uses: dorny/paths-filter@v3
|
||||
with:
|
||||
filters: |
|
||||
web:
|
||||
- 'apps/web/**'
|
||||
space:
|
||||
- 'apps/space/**'
|
||||
admin:
|
||||
- 'apps/admin/**'
|
||||
live:
|
||||
- 'apps/live/**'
|
||||
common:
|
||||
- 'packages/**'
|
||||
- 'turbo.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'Dockerfile.node'
|
||||
- 'Dockerfile.api'
|
||||
- 'Dockerfile.aio'
|
||||
- 'docker-bake.hcl'
|
||||
- '.github/workflows/docker-smoke-aio.yml'
|
||||
|
||||
- name: Compute AIO flag
|
||||
id: build-flag
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const anyCommon = '${{ steps.changes.outputs.common }}' === 'true';
|
||||
const changedWeb = '${{ steps.changes.outputs.web }}' === 'true';
|
||||
const changedSpace = '${{ steps.changes.outputs.space }}' === 'true';
|
||||
const changedAdmin = '${{ steps.changes.outputs.admin }}' === 'true';
|
||||
const changedLive = '${{ steps.changes.outputs.live }}' === 'true';
|
||||
const aioNeeded = anyCommon || changedWeb || changedSpace || changedAdmin || changedLive;
|
||||
core.setOutput('aio_needed', String(aioNeeded));
|
||||
|
||||
aio_smoke:
|
||||
name: Build and smoke test AIO
|
||||
runs-on: ubuntu-latest
|
||||
needs: determine-aio
|
||||
if: ${{ needs.determine-aio.outputs.aio_needed == 'true' }}
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Show Docker version
|
||||
run: |
|
||||
docker version
|
||||
docker info
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build AIO image with bake (load into local daemon)
|
||||
run: |
|
||||
docker buildx bake -f "./docker-bake.hcl" --load aio
|
||||
|
||||
- name: Run AIO smoke script
|
||||
run: |
|
||||
chmod +x "scripts/smoke-aio.sh"
|
||||
"scripts/smoke-aio.sh"
|
||||
@@ -0,0 +1,171 @@
|
||||
name: Docker build and smoke test for apps
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- "preview"
|
||||
paths:
|
||||
- "apps/web/**"
|
||||
- "apps/space/**"
|
||||
- "apps/admin/**"
|
||||
- "apps/live/**"
|
||||
- "packages/**"
|
||||
- "turbo.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "Dockerfile.node"
|
||||
- "Dockerfile.api"
|
||||
- "Dockerfile.aio"
|
||||
- "docker-bake.hcl"
|
||||
|
||||
- ".github/workflows/docker-smoke.yml"
|
||||
push:
|
||||
branches:
|
||||
- "preview"
|
||||
paths:
|
||||
- "apps/web/**"
|
||||
- "apps/space/**"
|
||||
- "apps/admin/**"
|
||||
- "apps/live/**"
|
||||
- "packages/**"
|
||||
- "turbo.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "Dockerfile.node"
|
||||
- "Dockerfile.api"
|
||||
- "Dockerfile.aio"
|
||||
- "docker-bake.hcl"
|
||||
|
||||
- ".github/workflows/docker-smoke.yml"
|
||||
|
||||
jobs:
|
||||
determine-matrix:
|
||||
name: Determine matrix
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.build-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect changed paths
|
||||
id: changes
|
||||
uses: dorny/paths-filter@v3
|
||||
with:
|
||||
filters: |
|
||||
web:
|
||||
- 'apps/web/**'
|
||||
space:
|
||||
- 'apps/space/**'
|
||||
admin:
|
||||
- 'apps/admin/**'
|
||||
live:
|
||||
- 'apps/live/**'
|
||||
common:
|
||||
- 'packages/**'
|
||||
- 'turbo.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'Dockerfile.node'
|
||||
- 'Dockerfile.api'
|
||||
- 'Dockerfile.aio'
|
||||
- 'docker-bake.hcl'
|
||||
- '.github/workflows/docker-smoke.yml'
|
||||
|
||||
- name: Build matrix
|
||||
id: build-matrix
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const include = [];
|
||||
const anyCommon = '${{ steps.changes.outputs.common }}' === 'true';
|
||||
const changed = {
|
||||
web: '${{ steps.changes.outputs.web }}' === 'true',
|
||||
space: '${{ steps.changes.outputs.space }}' === 'true',
|
||||
admin: '${{ steps.changes.outputs.admin }}' === 'true',
|
||||
live: '${{ steps.changes.outputs.live }}' === 'true',
|
||||
};
|
||||
const add = (name, bake_target, image, container, port, path, env_flags = "") =>
|
||||
include.push({ name, bake_target, image, container, host_port: port, path, env_flags });
|
||||
|
||||
const buildAll = anyCommon || changed.web || changed.space || changed.admin || changed.live;
|
||||
if (buildAll || changed.web) add('web', 'web', 'plane-web:ci-smoke', 'plane-web-ci', 3001, '/');
|
||||
if (buildAll || changed.space) add('space', 'space', 'plane-space:ci-smoke', 'plane-space-ci', 3002, '/spaces');
|
||||
if (buildAll || changed.admin) add('admin', 'admin', 'plane-admin:ci-smoke', 'plane-admin-ci', 3003, '/god-mode');
|
||||
if (buildAll || changed.live) add('live', 'live', 'plane-live:ci-smoke', 'plane-live-ci', 3005, '/live/health', '-e NODE_ENV=production -e LIVE_BASE_PATH=/live');
|
||||
|
||||
if (include.length === 0) {
|
||||
// Default to web to keep job non-empty
|
||||
add('web', 'web', 'plane-web:ci-smoke', 'plane-web-ci', 3001, '/');
|
||||
}
|
||||
core.setOutput('matrix', JSON.stringify({ include }));
|
||||
|
||||
smoke:
|
||||
name: Build and smoke test ${{ matrix.name }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: determine-matrix
|
||||
timeout-minutes: 25
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.determine-matrix.outputs.matrix) }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Prepare build environment
|
||||
run: echo "Using docker build (no buildx bake)"
|
||||
|
||||
- name: Show Docker version
|
||||
run: |
|
||||
docker version
|
||||
docker info
|
||||
|
||||
- name: Build image (${{ matrix.name }})
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
name="${{ matrix.name }}"
|
||||
tag="${{ matrix.image }}"
|
||||
if [ "$name" = "live" ]; then
|
||||
docker build -f "apps/live/Dockerfile.live" -t "$tag" "."
|
||||
else
|
||||
docker build -f "Dockerfile.node" --target runtime --build-arg APP_SCOPE="$name" -t "$tag" "."
|
||||
fi
|
||||
|
||||
- name: Run container (${{ matrix.name }})
|
||||
run: |
|
||||
docker run -d --name ${{ matrix.container }} -p ${{ matrix.host_port }}:3000 ${{ matrix.env_flags }} ${{ matrix.image }}
|
||||
docker ps -a
|
||||
|
||||
- name: Smoke test HTTP endpoint (${{ matrix.name }})
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
URL="http://localhost:${{ matrix.host_port }}${{ matrix.path }}"
|
||||
echo "Probing $URL ..."
|
||||
for i in {1..60}; do
|
||||
STATUS="$(curl -sS -o /dev/null -w "%{http_code}" -L "${URL}" || true)"
|
||||
if [ "${STATUS}" = "200" ]; then
|
||||
echo "Success: HTTP ${STATUS} from ${URL}"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt ${i}: HTTP ${STATUS} (waiting 2s)"
|
||||
sleep 2
|
||||
done
|
||||
echo "Failed to get HTTP 200 from ${URL}"
|
||||
echo "::group::Container logs (${{ matrix.container }})"
|
||||
docker logs ${{ matrix.container }} || true
|
||||
echo "::endgroup::"
|
||||
exit 1
|
||||
|
||||
- name: Cleanup container (${{ matrix.name }})
|
||||
if: always()
|
||||
run: |
|
||||
docker rm -f ${{ matrix.container }} || true
|
||||
@@ -3,11 +3,21 @@ name: Build and lint API
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: ["preview"]
|
||||
types: ["opened", "synchronize", "ready_for_review", "review_requested", "reopened"]
|
||||
branches:
|
||||
- "preview"
|
||||
types:
|
||||
- "opened"
|
||||
- "synchronize"
|
||||
- "ready_for_review"
|
||||
- "review_requested"
|
||||
- "reopened"
|
||||
paths:
|
||||
- "apps/api/**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint-api:
|
||||
name: Lint API
|
||||
|
||||
@@ -3,14 +3,18 @@ name: Build and lint web apps
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: ["preview"]
|
||||
types: ["opened", "synchronize", "ready_for_review", "review_requested", "reopened"]
|
||||
paths:
|
||||
- "**.tsx?"
|
||||
- "**.jsx?"
|
||||
- "**.css"
|
||||
- "**.json"
|
||||
- "!apps/api/**"
|
||||
branches:
|
||||
- "preview"
|
||||
types:
|
||||
- "opened"
|
||||
- "synchronize"
|
||||
- "ready_for_review"
|
||||
- "review_requested"
|
||||
- "reopened"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-lint:
|
||||
@@ -20,24 +24,32 @@ jobs:
|
||||
if: |
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.requested_reviewers != null
|
||||
env:
|
||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
fetch-depth: 50
|
||||
filter: blob:none
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Enable Corepack and pnpm
|
||||
run: corepack enable pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build web apps
|
||||
run: yarn run build
|
||||
|
||||
- name: Lint web apps
|
||||
run: yarn run ci:lint
|
||||
- name: Lint Affected
|
||||
run: pnpm turbo run check:lint --affected
|
||||
|
||||
- name: Check Affected format
|
||||
run: pnpm turbo run check:format --affected
|
||||
|
||||
- name: Build Affected
|
||||
run: pnpm turbo run build --affected
|
||||
|
||||
+7
-3
@@ -24,11 +24,13 @@ out/
|
||||
.DS_Store
|
||||
*.pem
|
||||
.history
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Local env files
|
||||
@@ -60,6 +62,7 @@ node_modules/
|
||||
assets/dist/
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
pnpm-debug.log
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
@@ -75,10 +78,9 @@ package-lock.json
|
||||
|
||||
# lock files
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
|
||||
.npmrc
|
||||
|
||||
|
||||
.secrets
|
||||
tmp/
|
||||
|
||||
@@ -95,3 +97,5 @@ dev-editor
|
||||
# Redis
|
||||
*.rdb
|
||||
*.rdb.gz
|
||||
|
||||
storybook-static
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Enforce pnpm workspace behavior and allow Turbo's lifecycle hooks if scripts are disabled
|
||||
# This repo uses pnpm with workspaces.
|
||||
|
||||
# Prefer linking local workspace packages when available
|
||||
prefer-workspace-packages=true
|
||||
link-workspace-packages=true
|
||||
shared-workspace-lockfile=true
|
||||
|
||||
# Make peer installs smoother across the monorepo
|
||||
auto-install-peers=true
|
||||
strict-peer-dependencies=false
|
||||
|
||||
# If scripts are disabled (e.g., CI with --ignore-scripts), allowlisted packages can still run their hooks
|
||||
# Turbo occasionally performs postinstall tasks for optimal performance
|
||||
# moved to pnpm-workspace.yaml: onlyBuiltDependencies (e.g., allow turbo)
|
||||
|
||||
public-hoist-pattern[]=eslint
|
||||
public-hoist-pattern[]=prettier
|
||||
public-hoist-pattern[]=typescript
|
||||
|
||||
# Reproducible installs across CI and dev
|
||||
prefer-frozen-lockfile=true
|
||||
|
||||
# Prefer resolving to highest versions in monorepo to reduce duplication
|
||||
resolution-mode=highest
|
||||
|
||||
# Speed up native module builds by caching side effects
|
||||
side-effects-cache=true
|
||||
|
||||
# Speed up local dev by reusing local store when possible
|
||||
prefer-offline=true
|
||||
|
||||
# Ensure workspace protocol is used when adding internal deps
|
||||
save-workspace-protocol=true
|
||||
@@ -1 +0,0 @@
|
||||
nodeLinker: node-modules
|
||||
+1
-1
@@ -73,7 +73,7 @@ docker compose -f docker-compose-local.yml up
|
||||
4. Start web apps:
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
5. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# All-in-one assembler image that composes Plane runtime from per-app images
|
||||
# and the community supervisor/Caddy configuration.
|
||||
#
|
||||
# Build requirements:
|
||||
# - Build and tag the per-app images first (or override args below):
|
||||
# plane-web:latest, plane-space:latest, plane-admin:latest, plane-live:latest,
|
||||
# plane-api:latest, plane-proxy:latest
|
||||
#
|
||||
# Example:
|
||||
# docker build \
|
||||
# -f plane/Dockerfile.aio \
|
||||
# --build-arg WEB_IMG=plane-web:latest \
|
||||
# --build-arg SPACE_IMG=plane-space:latest \
|
||||
# --build-arg ADMIN_IMG=plane-admin:latest \
|
||||
# --build-arg LIVE_IMG=plane-live:latest \
|
||||
# --build-arg API_IMG=plane-api:latest \
|
||||
# --build-arg PROXY_IMG=plane-proxy:latest \
|
||||
# -t plane-aio:latest .
|
||||
#
|
||||
# Run:
|
||||
# docker run --rm -it -p 80:80 plane-aio:latest
|
||||
#
|
||||
# Provide required env vars; see deployments/aio/community/README.md.
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Arguments to reference locally-built component images
|
||||
# ------------------------------------------------------------------------------
|
||||
# Build contexts are used for component images:
|
||||
# --build-context web_ctx=target:web
|
||||
# --build-context space_ctx=target:space
|
||||
# --build-context admin_ctx=target:admin
|
||||
# --build-context live_ctx=target:live
|
||||
# --build-context api_ctx=target:api
|
||||
# --build-context proxy_ctx=target:proxy
|
||||
ARG NODE_VERSION=22-alpine
|
||||
ARG PY_VERSION=3.12.10-alpine
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Source stages: pull artifacts from pre-built images
|
||||
# ------------------------------------------------------------------------------
|
||||
FROM node:${NODE_VERSION} AS node
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Final runner: Python as base (Supervisor + API deps live here)
|
||||
# ------------------------------------------------------------------------------
|
||||
FROM python:${PY_VERSION} AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Base system libs for API/Caddy/Node runtime
|
||||
RUN apk add --no-cache \
|
||||
libpq \
|
||||
libxslt \
|
||||
xmlsec \
|
||||
nss-tools \
|
||||
bash \
|
||||
curl \
|
||||
ca-certificates \
|
||||
openssl
|
||||
|
||||
# Install supervisor
|
||||
RUN pip install --no-cache-dir supervisor && mkdir -p /etc/supervisor/conf.d
|
||||
|
||||
# Copy Node runtime into the AIO container for Next standalone apps
|
||||
COPY --from=node /usr/lib /usr/lib
|
||||
COPY --from=node /usr/local/lib /usr/local/lib
|
||||
COPY --from=node /usr/local/include /usr/local/include
|
||||
COPY --from=node /usr/local/bin /usr/local/bin
|
||||
|
||||
# Copy Next.js standalone app artifacts (namespaced under /app/<app>)
|
||||
COPY --from=web_ctx /app /app/web
|
||||
COPY --from=space_ctx /app /app/space
|
||||
COPY --from=admin_ctx /app /app/admin
|
||||
COPY --from=live_ctx /app /app/live
|
||||
|
||||
# Clean potential Next caches (optional)
|
||||
RUN rm -rf /app/web/apps/web/.next/cache || true \
|
||||
&& rm -rf /app/space/apps/space/.next/cache || true \
|
||||
&& rm -rf /app/admin/apps/admin/.next/cache || true
|
||||
|
||||
# Copy Python backend code and installed packages/binaries
|
||||
COPY --from=api_ctx /code /app/backend
|
||||
# Match CPython version path (PY_VERSION=3.12.x-alpine)
|
||||
COPY --from=api_ctx /usr/local/lib/python3.12/site-packages/ /usr/local/lib/python3.12/site-packages/
|
||||
COPY --from=api_ctx /usr/local/bin/ /usr/local/bin/
|
||||
|
||||
# Caddy binary from proxy image
|
||||
COPY --from=proxy_ctx /usr/bin/caddy /usr/bin/caddy
|
||||
|
||||
# Community supervisor config and startup script
|
||||
COPY deployments/aio/community/start.sh /app/start.sh
|
||||
COPY deployments/aio/community/supervisor.conf /etc/supervisor/conf.d/supervisor.conf
|
||||
|
||||
# Provide a default plane.env from variables template; start.sh will update it
|
||||
COPY deployments/aio/community/variables.env /app/plane.env
|
||||
|
||||
# Prepare Caddy configuration:
|
||||
# - Start from the repository Caddyfile.ce
|
||||
# - Update upstreams to localhost-bound ports managed by supervisor
|
||||
RUN mkdir -p /app/proxy
|
||||
COPY apps/proxy/Caddyfile.ce /app/proxy/Caddyfile
|
||||
RUN set -eux; \
|
||||
sed -i 's|web:3000|localhost:3001|g' /app/proxy/Caddyfile; \
|
||||
sed -i 's|space:3000|localhost:3002|g' /app/proxy/Caddyfile; \
|
||||
sed -i 's|admin:3000|localhost:3003|g' /app/proxy/Caddyfile; \
|
||||
sed -i 's|api:8000|localhost:3004|g' /app/proxy/Caddyfile; \
|
||||
sed -i 's|live:3000|localhost:3005|g' /app/proxy/Caddyfile; \
|
||||
sed -i '/plane-minio:9000/d' /app/proxy/Caddyfile
|
||||
|
||||
# Folders and permissions
|
||||
RUN mkdir -p /app/logs/access /app/logs/error /app/data \
|
||||
&& chmod +x /app/start.sh
|
||||
|
||||
VOLUME ["/app/data", "/app/logs"]
|
||||
|
||||
EXPOSE 80 443
|
||||
|
||||
CMD ["/app/start.sh"]
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# Unified multi-stage Dockerfile for the Python API
|
||||
# Targets:
|
||||
# - dev: local development with hot reload and full build toolchain
|
||||
# - runtime: production runtime using prebuilt wheels
|
||||
#
|
||||
# Example builds (from repo root):
|
||||
# docker build -f plane/Dockerfile.api --target dev -t plane-api:dev .
|
||||
# docker build -f plane/Dockerfile.api --target runtime -t plane-api:latest .
|
||||
|
||||
ARG PY_VERSION=python:3.12.10-alpine
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# base: common runtime base (no build toolchain)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM ${PY_VERSION} AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
INSTANCE_CHANGELOG_URL=https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
|
||||
|
||||
# Runtime libraries required by the app
|
||||
RUN apk add --no-cache \
|
||||
libpq \
|
||||
libxslt \
|
||||
xmlsec \
|
||||
ca-certificates \
|
||||
openssl
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# builder: build Python wheels for all dependencies
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM ${PY_VERSION} AS builder
|
||||
|
||||
# Full build toolchain to compile deps to wheels
|
||||
RUN apk add --no-cache \
|
||||
bash~=5.2 \
|
||||
g++ \
|
||||
gcc \
|
||||
cargo \
|
||||
git \
|
||||
make \
|
||||
postgresql-dev \
|
||||
libc-dev \
|
||||
linux-headers \
|
||||
libffi-dev \
|
||||
libxml2-dev \
|
||||
libxslt-dev \
|
||||
openssl-dev \
|
||||
xmlsec-dev
|
||||
|
||||
WORKDIR /w
|
||||
|
||||
# Copy requirements (relative to repo root)
|
||||
COPY apps/api/requirements.txt /w/requirements.txt
|
||||
COPY apps/api/requirements /w/requirements
|
||||
|
||||
# Build wheels into /wheels to reuse in runtime
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip wheel -r /w/requirements.txt --wheel-dir /wheels
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# dev: local development image (bind mount source into /code)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM ${PY_VERSION} AS dev
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
INSTANCE_CHANGELOG_URL=https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
|
||||
|
||||
# Match prior dev environment: runtime + build deps + node
|
||||
RUN apk add --no-cache \
|
||||
libpq \
|
||||
libxslt \
|
||||
xmlsec \
|
||||
nodejs-current \
|
||||
bash~=5.2 \
|
||||
g++ \
|
||||
gcc \
|
||||
cargo \
|
||||
git \
|
||||
make \
|
||||
postgresql-dev \
|
||||
libc-dev \
|
||||
linux-headers \
|
||||
libffi-dev
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
# Copy and install local dev requirements
|
||||
COPY apps/api/requirements.txt ./requirements.txt
|
||||
COPY apps/api/requirements ./requirements
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install -r requirements/local.txt --no-cache-dir
|
||||
|
||||
# Bring in the API source
|
||||
COPY apps/api/ ./
|
||||
|
||||
# Permissions similar to existing Dockerfiles
|
||||
RUN mkdir -p /code/plane/logs \
|
||||
&& chmod -R +x /code/bin \
|
||||
&& chmod -R 777 /code
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["./bin/docker-entrypoint-api-local.sh"]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# runtime: production image using wheels from builder
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM base AS runtime
|
||||
|
||||
# Bash needed for entrypoint scripts
|
||||
RUN apk add --no-cache bash~=5.2
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
# Install from wheels for reproducible, fast builds
|
||||
COPY --from=builder /wheels /wheels
|
||||
COPY apps/api/requirements.txt ./requirements.txt
|
||||
COPY apps/api/requirements ./requirements
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt
|
||||
|
||||
# Copy only what is required to run
|
||||
COPY apps/api/manage.py ./manage.py
|
||||
COPY apps/api/plane ./plane
|
||||
COPY apps/api/templates ./templates
|
||||
COPY apps/api/package.json ./package.json
|
||||
COPY apps/api/bin ./bin
|
||||
|
||||
# Create unprivileged user and set secure permissions
|
||||
RUN addgroup -S plane && adduser -S -G plane -h /code -s /sbin/nologin plane \
|
||||
&& chmod +x ./bin/* \
|
||||
&& mkdir -p /code/plane/logs \
|
||||
&& chown -R plane:plane /code \
|
||||
&& chmod -R 755 /code \
|
||||
&& chmod 775 /code/plane/logs
|
||||
|
||||
ENV GUNICORN_WORKERS=3 \
|
||||
PORT=8000
|
||||
|
||||
USER plane
|
||||
EXPOSE 8000
|
||||
|
||||
# Default: API server; override command for worker/beat/migrator as needed
|
||||
CMD ["./bin/docker-entrypoint-api.sh"]
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# Unified multi-stage Dockerfile for Next.js apps in this monorepo.
|
||||
# Supports:
|
||||
# - target=dev (hot-reload, expects bind-mounted repo)
|
||||
# - target=runtime (production image using Next.js standalone output)
|
||||
#
|
||||
# Usage examples:
|
||||
# - Build dev image for web: docker build --target dev --build-arg APP_SCOPE=web -t plane-web:dev .
|
||||
# - Run dev (with compose): mount the repo into /repo and override command/ports as needed
|
||||
# - Build prod image for web: docker build --target runtime --build-arg APP_SCOPE=web -t plane-web:latest .
|
||||
#
|
||||
# APP_SCOPE must be one of: web, space, admin
|
||||
|
||||
ARG NODE_VERSION=22-alpine
|
||||
ARG TURBO_VERSION=2.5.6
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Base: Node + pnpm (corepack)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:${NODE_VERSION} AS base
|
||||
|
||||
ENV PNPM_HOME=/pnpm
|
||||
ENV PATH=$PNPM_HOME:$PATH
|
||||
RUN corepack enable && apk add --no-cache libc6-compat
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# builder: prune workspace using turbo for the selected app scope
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM base AS builder
|
||||
ARG APP_SCOPE
|
||||
WORKDIR /repo
|
||||
|
||||
# Full context is required for turbo prune
|
||||
COPY . .
|
||||
RUN pnpm add -g turbo@${TURBO_VERSION}
|
||||
RUN turbo prune --scope=${APP_SCOPE} --docker
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# installer: install deps (offline) and build the selected scope
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM base AS installer
|
||||
WORKDIR /repo
|
||||
|
||||
# Seed minimal files for reproducible installs and good caching
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=builder /repo/out/json/ .
|
||||
COPY --from=builder /repo/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
|
||||
# Fetch dependencies into a cached store layer
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||
pnpm fetch --store-dir=/pnpm/store
|
||||
|
||||
# Bring in only the pruned workspace for fast installs/builds
|
||||
COPY --from=builder /repo/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
|
||||
# Offline, frozen lockfile install from cached store
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||
pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store
|
||||
|
||||
# Build-time environment (safe to pass through; only NEXT_PUBLIC_* are embedded)
|
||||
# Keep parity with existing app Dockerfiles
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_WEB_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
# Build only the selected scope
|
||||
ARG APP_SCOPE
|
||||
RUN pnpm turbo run build --filter=${APP_SCOPE}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# dev: for local development with hot reload (bind-mount the repo to /repo)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM base AS dev
|
||||
WORKDIR /repo
|
||||
|
||||
# Helpful global tool for selective builds/dev
|
||||
RUN pnpm add -g turbo@${TURBO_VERSION}
|
||||
|
||||
ENV NODE_ENV=development \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
# Select which app to run in dev (web|space|admin)
|
||||
ARG APP_SCOPE
|
||||
ENV APP_SCOPE=${APP_SCOPE}
|
||||
|
||||
EXPOSE 3000
|
||||
# Expect the source to be bind-mounted; install deps and start dev server
|
||||
CMD ["sh", "-lc", "pnpm install && pnpm dev --filter=${APP_SCOPE}"]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# runtime: minimal Next.js standalone runner for the selected scope
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:${NODE_VERSION} AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
TURBO_TELEMETRY_DISABLED=1 \
|
||||
HOSTNAME=0.0.0.0 \
|
||||
PORT=3000
|
||||
|
||||
# Drop privileges
|
||||
RUN addgroup -S -g 1001 nodejs \
|
||||
&& adduser -S -u 1001 -G nodejs -h /home/nextjs -D nextjs \
|
||||
&& mkdir -p /home/nextjs \
|
||||
&& chown -R nextjs:nodejs /home/nextjs
|
||||
USER nextjs
|
||||
|
||||
# Copy only the built output for the selected scope
|
||||
ARG APP_SCOPE
|
||||
ENV APP_SCOPE=${APP_SCOPE}
|
||||
# Next.js standalone layout
|
||||
COPY --from=installer /repo/apps/${APP_SCOPE}/.next/standalone ./
|
||||
COPY --from=installer /repo/apps/${APP_SCOPE}/.next/static ./apps/${APP_SCOPE}/.next/static
|
||||
COPY --from=installer /repo/apps/${APP_SCOPE}/public ./apps/${APP_SCOPE}/public
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["sh", "-lc", "set -e; : \"${APP_SCOPE:?APP_SCOPE env is required}\"; SERVER=\"apps/${APP_SCOPE}/server.js\"; if [ ! -f \"$SERVER\" ]; then echo \"Error: $SERVER not found.\"; ls -la \"apps/${APP_SCOPE}\" || true; echo \"Known apps:\"; ls -1 apps || true; exit 1; fi; exec node \"$SERVER\""]
|
||||
@@ -2,5 +2,5 @@
|
||||
.vercel
|
||||
.tubro
|
||||
out/
|
||||
dis/
|
||||
build/
|
||||
dist/
|
||||
build/
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 1: Build the project
|
||||
# *****************************************************************************
|
||||
FROM base AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
RUN yarn global add turbo
|
||||
COPY . .
|
||||
|
||||
RUN turbo prune --scope=admin --docker
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 2: Install dependencies & build the project
|
||||
# *****************************************************************************
|
||||
FROM base AS installer
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/yarn.lock ./yarn.lock
|
||||
RUN yarn install --network-timeout 500000
|
||||
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_WEB_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN yarn turbo run build --filter=admin
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 3: Copy the project and start it
|
||||
# *****************************************************************************
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
USER nextjs
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=installer /app/apps/admin/.next/standalone ./
|
||||
COPY --from=installer /app/apps/admin/.next/static ./apps/admin/.next/static
|
||||
COPY --from=installer /app/apps/admin/public ./apps/admin/public
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_WEB_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "apps/admin/server.js"]
|
||||
@@ -1,17 +0,0 @@
|
||||
FROM node:22-alpine
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN yarn global add turbo
|
||||
RUN yarn install
|
||||
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
VOLUME [ "/app/node_modules", "/app/admin/node_modules" ]
|
||||
|
||||
CMD ["yarn", "dev", "--filter=admin"]
|
||||
+12
-12
@@ -18,15 +18,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.19",
|
||||
"@plane/constants": "*",
|
||||
"@plane/hooks": "*",
|
||||
"@plane/propel": "*",
|
||||
"@plane/services": "*",
|
||||
"@plane/types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@plane/utils": "*",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/lodash": "^4.17.0",
|
||||
"@plane/constants": "workspace:*",
|
||||
"@plane/hooks": "workspace:*",
|
||||
"@plane/propel": "workspace:*",
|
||||
"@plane/services": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"@plane/ui": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"autoprefixer": "10.4.14",
|
||||
"axios": "1.11.0",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -39,13 +37,15 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "7.51.5",
|
||||
"sharp": "^0.33.5",
|
||||
"swr": "^2.2.4",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
"@plane/tailwind-config": "*",
|
||||
"@plane/typescript-config": "*",
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/lodash": "^4.17.6",
|
||||
"@types/node": "18.16.1",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
FROM python:3.12.10-alpine
|
||||
|
||||
# set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
ENV INSTANCE_CHANGELOG_URL=https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
|
||||
|
||||
# Update system packages for security
|
||||
RUN apk update && apk upgrade
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
RUN apk add --no-cache --upgrade \
|
||||
"libpq" \
|
||||
"libxslt" \
|
||||
"xmlsec" \
|
||||
"ca-certificates" \
|
||||
"openssl"
|
||||
|
||||
COPY requirements.txt ./
|
||||
COPY requirements ./requirements
|
||||
RUN apk add --no-cache libffi-dev
|
||||
RUN apk add --no-cache --virtual .build-deps \
|
||||
"bash~=5.2" \
|
||||
"g++" \
|
||||
"gcc" \
|
||||
"cargo" \
|
||||
"git" \
|
||||
"make" \
|
||||
"postgresql-dev" \
|
||||
"libc-dev" \
|
||||
"linux-headers" \
|
||||
&& \
|
||||
pip install -r requirements.txt --compile --no-cache-dir \
|
||||
&& \
|
||||
apk del .build-deps \
|
||||
&& \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
|
||||
# Add in Django deps and generate Django's static files
|
||||
COPY manage.py manage.py
|
||||
COPY plane plane/
|
||||
COPY templates templates/
|
||||
COPY package.json package.json
|
||||
|
||||
RUN apk --no-cache add "bash~=5.2"
|
||||
COPY ./bin ./bin/
|
||||
|
||||
RUN mkdir -p /code/plane/logs
|
||||
RUN chmod +x ./bin/*
|
||||
RUN chmod -R 777 /code
|
||||
|
||||
# Expose container port and run entry point script
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["./bin/docker-entrypoint-api.sh"]
|
||||
@@ -1,46 +0,0 @@
|
||||
FROM python:3.12.5-alpine AS backend
|
||||
|
||||
# set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
|
||||
|
||||
RUN apk --no-cache add \
|
||||
"bash~=5.2" \
|
||||
"libpq" \
|
||||
"libxslt" \
|
||||
"nodejs-current" \
|
||||
"xmlsec" \
|
||||
"libffi-dev" \
|
||||
"bash~=5.2" \
|
||||
"g++" \
|
||||
"gcc" \
|
||||
"cargo" \
|
||||
"git" \
|
||||
"make" \
|
||||
"postgresql-dev" \
|
||||
"libc-dev" \
|
||||
"linux-headers"
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
COPY requirements.txt ./requirements.txt
|
||||
ADD requirements ./requirements
|
||||
|
||||
# Install the local development settings
|
||||
RUN pip install -r requirements/local.txt --compile --no-cache-dir
|
||||
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /code/plane/logs
|
||||
RUN chmod -R +x /code/bin
|
||||
RUN chmod -R 777 /code
|
||||
|
||||
|
||||
# Expose container port and run entry point script
|
||||
EXPOSE 8000
|
||||
|
||||
CMD [ "./bin/docker-entrypoint-api-local.sh" ]
|
||||
|
||||
@@ -24,7 +24,6 @@ from plane.db.models import (
|
||||
)
|
||||
from plane.utils.content_validator import (
|
||||
validate_html_content,
|
||||
validate_json_content,
|
||||
validate_binary_data,
|
||||
)
|
||||
|
||||
@@ -89,20 +88,24 @@ class IssueSerializer(BaseSerializer):
|
||||
raise serializers.ValidationError("Invalid HTML passed")
|
||||
|
||||
# Validate description content for security
|
||||
if data.get("description"):
|
||||
is_valid, error_msg = validate_json_content(data["description"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description": error_msg})
|
||||
|
||||
if data.get("description_html"):
|
||||
is_valid, error_msg = validate_html_content(data["description_html"])
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(
|
||||
data["description_html"]
|
||||
)
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_html": error_msg})
|
||||
raise serializers.ValidationError(
|
||||
{"error": "html content is not valid"}
|
||||
)
|
||||
# Update the data with sanitized HTML if available
|
||||
if sanitized_html is not None:
|
||||
data["description_html"] = sanitized_html
|
||||
|
||||
if data.get("description_binary"):
|
||||
is_valid, error_msg = validate_binary_data(data["description_binary"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_binary": error_msg})
|
||||
raise serializers.ValidationError(
|
||||
{"description_binary": "Invalid binary data"}
|
||||
)
|
||||
|
||||
# Validate assignees are from project
|
||||
if data.get("assignees", []):
|
||||
|
||||
@@ -12,7 +12,6 @@ from plane.db.models import (
|
||||
|
||||
from plane.utils.content_validator import (
|
||||
validate_html_content,
|
||||
validate_json_content,
|
||||
)
|
||||
from .base import BaseSerializer
|
||||
|
||||
@@ -45,6 +44,10 @@ class ProjectCreateSerializer(BaseSerializer):
|
||||
"archive_in",
|
||||
"close_in",
|
||||
"timezone",
|
||||
"logo_props",
|
||||
"external_source",
|
||||
"external_id",
|
||||
"is_issue_type_enabled",
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
@@ -196,27 +199,18 @@ class ProjectSerializer(BaseSerializer):
|
||||
)
|
||||
|
||||
# Validate description content for security
|
||||
if "description" in data and data["description"]:
|
||||
# For Project, description might be text field, not JSON
|
||||
if isinstance(data["description"], dict):
|
||||
is_valid, error_msg = validate_json_content(data["description"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description": error_msg})
|
||||
|
||||
if "description_text" in data and data["description_text"]:
|
||||
is_valid, error_msg = validate_json_content(data["description_text"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_text": error_msg})
|
||||
|
||||
if "description_html" in data and data["description_html"]:
|
||||
if isinstance(data["description_html"], dict):
|
||||
is_valid, error_msg = validate_json_content(data["description_html"])
|
||||
else:
|
||||
is_valid, error_msg = validate_html_content(
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(
|
||||
str(data["description_html"])
|
||||
)
|
||||
# Update the data with sanitized HTML if available
|
||||
if sanitized_html is not None:
|
||||
data["description_html"] = sanitized_html
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_html": error_msg})
|
||||
raise serializers.ValidationError(
|
||||
{"error": "html content is not valid"}
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -89,7 +89,9 @@ urlpatterns = [
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/",
|
||||
IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "delete"]),
|
||||
IssueAttachmentDetailAPIEndpoint.as_view(
|
||||
http_method_names=["get", "patch", "delete"]
|
||||
),
|
||||
name="issue-attachment",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ from plane.api.views import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<str:project_id>/members/",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/members/",
|
||||
ProjectMemberAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="project-members",
|
||||
),
|
||||
|
||||
@@ -75,7 +75,7 @@ from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from .base import BaseAPIView
|
||||
from plane.utils.host import base_host
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
|
||||
from plane.app.permissions import ROLE
|
||||
from plane.utils.openapi import (
|
||||
work_item_docs,
|
||||
label_docs,
|
||||
@@ -145,6 +145,22 @@ from plane.utils.openapi import (
|
||||
)
|
||||
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
|
||||
|
||||
def user_has_issue_permission(
|
||||
user_id, project_id, issue=None, allowed_roles=None, allow_creator=True
|
||||
):
|
||||
if allow_creator and issue is not None and user_id == issue.created_by_id:
|
||||
return True
|
||||
|
||||
qs = ProjectMember.objects.filter(
|
||||
project_id=project_id,
|
||||
member_id=user_id,
|
||||
is_active=True,
|
||||
)
|
||||
if allowed_roles is not None:
|
||||
qs = qs.filter(role__in=allowed_roles)
|
||||
|
||||
return qs.exists()
|
||||
|
||||
|
||||
class WorkspaceIssueAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
@@ -331,6 +347,10 @@ class IssueListCreateAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
)
|
||||
|
||||
total_issue_queryset = Issue.issue_objects.filter(
|
||||
project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
|
||||
# Priority Ordering
|
||||
if order_by_param == "priority" or order_by_param == "-priority":
|
||||
priority_order = (
|
||||
@@ -390,6 +410,7 @@ class IssueListCreateAPIEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(issue_queryset),
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: IssueSerializer(
|
||||
issues, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
@@ -1782,7 +1803,6 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
|
||||
|
||||
serializer_class = IssueAttachmentSerializer
|
||||
model = FileAsset
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
use_read_replica = True
|
||||
|
||||
@issue_attachment_docs(
|
||||
@@ -1865,6 +1885,22 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
|
||||
Generate presigned URL for uploading file attachments to a work item.
|
||||
Validates file type and size before creating the attachment record.
|
||||
"""
|
||||
issue = Issue.objects.get(
|
||||
pk=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
# if the user is creator or admin,member then allow the upload
|
||||
if not user_has_issue_permission(
|
||||
request.user.id,
|
||||
project_id=project_id,
|
||||
issue=issue,
|
||||
allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
allow_creator=True,
|
||||
):
|
||||
return Response(
|
||||
{"error": "You are not allowed to upload this attachment"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
name = request.data.get("name")
|
||||
type = request.data.get("type", False)
|
||||
size = request.data.get("size")
|
||||
@@ -1989,7 +2025,6 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
|
||||
"""Issue Attachment Detail Endpoint"""
|
||||
|
||||
serializer_class = IssueAttachmentSerializer
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
model = FileAsset
|
||||
use_read_replica = True
|
||||
|
||||
@@ -2012,6 +2047,22 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
|
||||
Soft delete an attachment from a work item by marking it as deleted.
|
||||
Records deletion activity and triggers metadata cleanup.
|
||||
"""
|
||||
issue = Issue.objects.get(
|
||||
pk=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
# if the request user is creator or admin then delete the attachment
|
||||
if not user_has_issue_permission(
|
||||
request.user,
|
||||
project_id=project_id,
|
||||
issue=issue,
|
||||
allowed_roles=[ROLE.ADMIN.value],
|
||||
allow_creator=True,
|
||||
):
|
||||
return Response(
|
||||
{"error": "You are not allowed to delete this attachment"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
issue_attachment = FileAsset.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
@@ -2074,6 +2125,19 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
|
||||
|
||||
Retrieve details of a specific attachment.
|
||||
"""
|
||||
# if the user is part of the project then allow the download
|
||||
if not user_has_issue_permission(
|
||||
request.user,
|
||||
project_id=project_id,
|
||||
issue=None,
|
||||
allowed_roles=None,
|
||||
allow_creator=False,
|
||||
):
|
||||
return Response(
|
||||
{"error": "You are not allowed to download this attachment"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# Get the asset
|
||||
asset = FileAsset.objects.get(
|
||||
id=pk, workspace__slug=slug, project_id=project_id
|
||||
@@ -2128,6 +2192,23 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
|
||||
Mark an attachment as uploaded after successful file transfer to storage.
|
||||
Triggers activity logging and metadata extraction.
|
||||
"""
|
||||
|
||||
issue = Issue.objects.get(
|
||||
pk=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
# if the user is creator or admin then allow the upload
|
||||
if not user_has_issue_permission(
|
||||
request.user,
|
||||
project_id=project_id,
|
||||
issue=issue,
|
||||
allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
allow_creator=True,
|
||||
):
|
||||
return Response(
|
||||
{"error": "You are not allowed to upload this attachment"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
issue_attachment = FileAsset.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
|
||||
@@ -23,7 +23,6 @@ from plane.db.models import (
|
||||
)
|
||||
from plane.utils.content_validator import (
|
||||
validate_html_content,
|
||||
validate_json_content,
|
||||
validate_binary_data,
|
||||
)
|
||||
from plane.app.permissions import ROLE
|
||||
@@ -76,20 +75,24 @@ class DraftIssueCreateSerializer(BaseSerializer):
|
||||
raise serializers.ValidationError("Start date cannot exceed target date")
|
||||
|
||||
# Validate description content for security
|
||||
if "description" in attrs and attrs["description"]:
|
||||
is_valid, error_msg = validate_json_content(attrs["description"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description": error_msg})
|
||||
|
||||
if "description_html" in attrs and attrs["description_html"]:
|
||||
is_valid, error_msg = validate_html_content(attrs["description_html"])
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(
|
||||
attrs["description_html"]
|
||||
)
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_html": error_msg})
|
||||
raise serializers.ValidationError(
|
||||
{"error": "html content is not valid"}
|
||||
)
|
||||
# Update the attrs with sanitized HTML if available
|
||||
if sanitized_html is not None:
|
||||
attrs["description_html"] = sanitized_html
|
||||
|
||||
if "description_binary" in attrs and attrs["description_binary"]:
|
||||
is_valid, error_msg = validate_binary_data(attrs["description_binary"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_binary": error_msg})
|
||||
raise serializers.ValidationError(
|
||||
{"description_binary": "Invalid binary data"}
|
||||
)
|
||||
|
||||
# Validate assignees are from project
|
||||
if attrs.get("assignee_ids", []):
|
||||
|
||||
@@ -43,7 +43,6 @@ from plane.db.models import (
|
||||
)
|
||||
from plane.utils.content_validator import (
|
||||
validate_html_content,
|
||||
validate_json_content,
|
||||
validate_binary_data,
|
||||
)
|
||||
|
||||
@@ -128,20 +127,24 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
raise serializers.ValidationError("Start date cannot exceed target date")
|
||||
|
||||
# Validate description content for security
|
||||
if "description" in attrs and attrs["description"]:
|
||||
is_valid, error_msg = validate_json_content(attrs["description"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description": error_msg})
|
||||
|
||||
if "description_html" in attrs and attrs["description_html"]:
|
||||
is_valid, error_msg = validate_html_content(attrs["description_html"])
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(
|
||||
attrs["description_html"]
|
||||
)
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_html": error_msg})
|
||||
raise serializers.ValidationError(
|
||||
{"error": "html content is not valid"}
|
||||
)
|
||||
# Update the attrs with sanitized HTML if available
|
||||
if sanitized_html is not None:
|
||||
attrs["description_html"] = sanitized_html
|
||||
|
||||
if "description_binary" in attrs and attrs["description_binary"]:
|
||||
is_valid, error_msg = validate_binary_data(attrs["description_binary"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_binary": error_msg})
|
||||
raise serializers.ValidationError(
|
||||
{"description_binary": "Invalid binary data"}
|
||||
)
|
||||
|
||||
# Validate assignees are from project
|
||||
if attrs.get("assignee_ids", []):
|
||||
@@ -908,9 +911,14 @@ class IssueLiteSerializer(DynamicBaseSerializer):
|
||||
class IssueDetailSerializer(IssueSerializer):
|
||||
description_html = serializers.CharField()
|
||||
is_subscribed = serializers.BooleanField(read_only=True)
|
||||
is_intake = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta(IssueSerializer.Meta):
|
||||
fields = IssueSerializer.Meta.fields + ["description_html", "is_subscribed"]
|
||||
fields = IssueSerializer.Meta.fields + [
|
||||
"description_html",
|
||||
"is_subscribed",
|
||||
"is_intake",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ from .base import BaseSerializer
|
||||
from plane.utils.content_validator import (
|
||||
validate_binary_data,
|
||||
validate_html_content,
|
||||
validate_json_content,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Page,
|
||||
@@ -229,23 +228,13 @@ class PageBinaryUpdateSerializer(serializers.Serializer):
|
||||
return value
|
||||
|
||||
# Use the validation function from utils
|
||||
is_valid, error_message = validate_html_content(value)
|
||||
is_valid, error_message, sanitized_html = validate_html_content(value)
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError(error_message)
|
||||
|
||||
return value
|
||||
# Return sanitized HTML if available, otherwise return original
|
||||
return sanitized_html if sanitized_html is not None else value
|
||||
|
||||
def validate_description(self, value):
|
||||
"""Validate the JSON description"""
|
||||
if not value:
|
||||
return value
|
||||
|
||||
# Use the validation function from utils
|
||||
is_valid, error_message = validate_json_content(value)
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError(error_message)
|
||||
|
||||
return value
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Update the page instance with validated data"""
|
||||
|
||||
@@ -15,7 +15,6 @@ from plane.db.models import (
|
||||
)
|
||||
from plane.utils.content_validator import (
|
||||
validate_html_content,
|
||||
validate_json_content,
|
||||
validate_binary_data,
|
||||
)
|
||||
|
||||
@@ -65,27 +64,18 @@ class ProjectSerializer(BaseSerializer):
|
||||
|
||||
def validate(self, data):
|
||||
# Validate description content for security
|
||||
if "description" in data and data["description"]:
|
||||
# For Project, description might be text field, not JSON
|
||||
if isinstance(data["description"], dict):
|
||||
is_valid, error_msg = validate_json_content(data["description"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description": error_msg})
|
||||
|
||||
if "description_text" in data and data["description_text"]:
|
||||
is_valid, error_msg = validate_json_content(data["description_text"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_text": error_msg})
|
||||
|
||||
if "description_html" in data and data["description_html"]:
|
||||
if isinstance(data["description_html"], dict):
|
||||
is_valid, error_msg = validate_json_content(data["description_html"])
|
||||
else:
|
||||
is_valid, error_msg = validate_html_content(
|
||||
str(data["description_html"])
|
||||
)
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(
|
||||
str(data["description_html"])
|
||||
)
|
||||
# Update the data with sanitized HTML if available
|
||||
if sanitized_html is not None:
|
||||
data["description_html"] = sanitized_html
|
||||
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_html": error_msg})
|
||||
raise serializers.ValidationError(
|
||||
{"error": "html content is not valid"}
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
|
||||
from plane.utils.url import contains_url
|
||||
from plane.utils.content_validator import (
|
||||
validate_html_content,
|
||||
validate_json_content,
|
||||
validate_binary_data,
|
||||
)
|
||||
|
||||
@@ -319,20 +318,24 @@ class StickySerializer(BaseSerializer):
|
||||
|
||||
def validate(self, data):
|
||||
# Validate description content for security
|
||||
if "description" in data and data["description"]:
|
||||
is_valid, error_msg = validate_json_content(data["description"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description": error_msg})
|
||||
|
||||
if "description_html" in data and data["description_html"]:
|
||||
is_valid, error_msg = validate_html_content(data["description_html"])
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(
|
||||
data["description_html"]
|
||||
)
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_html": error_msg})
|
||||
raise serializers.ValidationError(
|
||||
{"error": "html content is not valid"}
|
||||
)
|
||||
# Update the data with sanitized HTML if available
|
||||
if sanitized_html is not None:
|
||||
data["description_html"] = sanitized_html
|
||||
|
||||
if "description_binary" in data and data["description_binary"]:
|
||||
is_valid, error_msg = validate_binary_data(data["description_binary"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_binary": error_msg})
|
||||
raise serializers.ValidationError(
|
||||
{"description_binary": "Invalid binary data"}
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
|
||||
# Django imports
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import F, Func, OuterRef, Q, Prefetch, Exists, Subquery
|
||||
from django.db.models import F, Func, OuterRef, Q, Prefetch, Exists, Subquery, Count
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
@@ -69,25 +69,31 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
link_count=Subquery(
|
||||
IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.values("issue")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
attachment_count=Subquery(
|
||||
FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.values("issue_id")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Subquery(
|
||||
Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.values("parent")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -101,6 +107,19 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
|
||||
total_issue_queryset = Issue.objects.filter(
|
||||
deleted_at__isnull=True,
|
||||
archived_at__isnull=False,
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
).filter(**filters)
|
||||
|
||||
total_issue_queryset = (
|
||||
total_issue_queryset
|
||||
if show_sub_issues == "true"
|
||||
else total_issue_queryset.filter(parent__isnull=True)
|
||||
)
|
||||
|
||||
issue_queryset = (
|
||||
issue_queryset
|
||||
if show_sub_issues == "true"
|
||||
@@ -136,6 +155,7 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@@ -170,6 +190,7 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@@ -196,6 +217,7 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
order_by=order_by_param,
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
|
||||
@@ -51,6 +51,7 @@ from plane.db.models import (
|
||||
IssueRelation,
|
||||
IssueAssignee,
|
||||
IssueLabel,
|
||||
IntakeIssue,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
@@ -213,27 +214,33 @@ class IssueViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
link_count=Subquery(
|
||||
IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.values("issue")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
attachment_count=Subquery(
|
||||
FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.values("issue_id")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
).distinct()
|
||||
.annotate(
|
||||
sub_issues_count=Subquery(
|
||||
Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.values("parent")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
@@ -249,6 +256,10 @@ class IssueViewSet(BaseViewSet):
|
||||
issue_queryset = self.get_queryset().filter(**filters, **extra_filters)
|
||||
# Custom ordering for priority and state
|
||||
|
||||
total_issue_queryset = Issue.issue_objects.filter(
|
||||
project_id=project_id, workspace__slug=slug
|
||||
).filter(**filters, **extra_filters)
|
||||
|
||||
# Issue queryset
|
||||
issue_queryset, order_by_param = order_issue_queryset(
|
||||
issue_queryset=issue_queryset, order_by_param=order_by_param
|
||||
@@ -281,6 +292,7 @@ class IssueViewSet(BaseViewSet):
|
||||
and not project.guest_view_all_features
|
||||
):
|
||||
issue_queryset = issue_queryset.filter(created_by=request.user)
|
||||
total_issue_queryset = total_issue_queryset.filter(created_by=request.user)
|
||||
|
||||
if group_by:
|
||||
if sub_group_by:
|
||||
@@ -296,6 +308,7 @@ class IssueViewSet(BaseViewSet):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@@ -329,6 +342,7 @@ class IssueViewSet(BaseViewSet):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@@ -354,6 +368,7 @@ class IssueViewSet(BaseViewSet):
|
||||
order_by=order_by_param,
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@@ -1209,7 +1224,7 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
|
||||
|
||||
# Fetch the issue
|
||||
issue = (
|
||||
Issue.issue_objects.filter(project_id=project.id)
|
||||
Issue.objects.filter(project_id=project.id)
|
||||
.filter(workspace__slug=slug)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
@@ -1301,6 +1316,16 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
is_intake=Exists(
|
||||
IntakeIssue.objects.filter(
|
||||
issue=OuterRef("id"),
|
||||
status__in=[-2, 0],
|
||||
workspace__slug=slug,
|
||||
project_id=project.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
).first()
|
||||
|
||||
# Check if the issue exists
|
||||
|
||||
@@ -59,9 +59,10 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
related_issue_ids = [item for sublist in related_issue_ids for item in sublist]
|
||||
related_issue_ids.append(issue_id)
|
||||
|
||||
if issue:
|
||||
issues = issues.filter(~Q(pk=issue_id), ~Q(pk__in=related_issue_ids))
|
||||
issues = issues.exclude(pk__in=related_issue_ids)
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from plane.db.models import APIActivityLog
|
||||
from celery import shared_task
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_api_logs():
|
||||
# Get the logs older than 30 days to delete
|
||||
logs_to_delete = APIActivityLog.objects.filter(
|
||||
created_at__lte=timezone.now() - timedelta(days=30)
|
||||
)
|
||||
|
||||
# Delete the logs
|
||||
logs_to_delete._raw_delete(logs_to_delete.db)
|
||||
@@ -0,0 +1,423 @@
|
||||
# Python imports
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import List, Dict, Any, Callable, Optional
|
||||
import os
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import F, Window, Subquery
|
||||
from django.db.models.functions import RowNumber
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from pymongo.errors import BulkWriteError
|
||||
from pymongo.collection import Collection
|
||||
from pymongo.operations import InsertOne
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
EmailNotificationLog,
|
||||
PageVersion,
|
||||
APIActivityLog,
|
||||
IssueDescriptionVersion,
|
||||
)
|
||||
from plane.settings.mongo import MongoConnection
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
BATCH_SIZE = 1000
|
||||
|
||||
|
||||
def get_mongo_collection(collection_name: str) -> Optional[Collection]:
|
||||
"""Get MongoDB collection if available, otherwise return None."""
|
||||
if not MongoConnection.is_configured():
|
||||
logger.info("MongoDB not configured")
|
||||
return None
|
||||
|
||||
try:
|
||||
mongo_collection = MongoConnection.get_collection(collection_name)
|
||||
logger.info(f"MongoDB collection '{collection_name}' connected successfully")
|
||||
return mongo_collection
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get MongoDB collection: {str(e)}")
|
||||
log_exception(e)
|
||||
return None
|
||||
|
||||
|
||||
def flush_to_mongo_and_delete(
|
||||
mongo_collection: Optional[Collection],
|
||||
buffer: List[Dict[str, Any]],
|
||||
ids_to_delete: List[int],
|
||||
model,
|
||||
mongo_available: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Inserts a batch of records into MongoDB and deletes the corresponding rows from PostgreSQL.
|
||||
"""
|
||||
if not buffer:
|
||||
logger.debug("No records to flush - buffer is empty")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Starting batch flush: {len(buffer)} records, {len(ids_to_delete)} IDs to delete"
|
||||
)
|
||||
|
||||
mongo_archival_failed = False
|
||||
|
||||
# Try to insert into MongoDB if available
|
||||
if mongo_collection and mongo_available:
|
||||
try:
|
||||
mongo_collection.bulk_write([InsertOne(doc) for doc in buffer])
|
||||
except BulkWriteError as bwe:
|
||||
logger.error(f"MongoDB bulk write error: {str(bwe)}")
|
||||
log_exception(bwe)
|
||||
mongo_archival_failed = True
|
||||
|
||||
# If MongoDB is available and archival failed, log the error and return
|
||||
if mongo_available and mongo_archival_failed:
|
||||
logger.error(f"MongoDB archival failed for {len(buffer)} records")
|
||||
return
|
||||
|
||||
# Delete from PostgreSQL - delete() returns (count, {model: count})
|
||||
delete_result = model.all_objects.filter(id__in=ids_to_delete).delete()
|
||||
deleted_count = (
|
||||
delete_result[0] if delete_result and isinstance(delete_result, tuple) else 0
|
||||
)
|
||||
logger.info(f"Batch flush completed: {deleted_count} records deleted")
|
||||
|
||||
|
||||
def process_cleanup_task(
|
||||
queryset_func: Callable,
|
||||
transform_func: Callable[[Dict], Dict],
|
||||
model,
|
||||
task_name: str,
|
||||
collection_name: str,
|
||||
):
|
||||
"""
|
||||
Generic function to process cleanup tasks.
|
||||
|
||||
Args:
|
||||
queryset_func: Function that returns the queryset to process
|
||||
transform_func: Function to transform each record for MongoDB
|
||||
model: Django model class
|
||||
task_name: Name of the task for logging
|
||||
collection_name: MongoDB collection name
|
||||
"""
|
||||
logger.info(f"Starting {task_name} cleanup task")
|
||||
|
||||
# Get MongoDB collection
|
||||
mongo_collection = get_mongo_collection(collection_name)
|
||||
mongo_available = mongo_collection is not None
|
||||
|
||||
# Get queryset
|
||||
queryset = queryset_func()
|
||||
|
||||
# Process records in batches
|
||||
buffer: List[Dict[str, Any]] = []
|
||||
ids_to_delete: List[int] = []
|
||||
total_processed = 0
|
||||
total_batches = 0
|
||||
|
||||
for record in queryset:
|
||||
# Transform record for MongoDB
|
||||
buffer.append(transform_func(record))
|
||||
ids_to_delete.append(record["id"])
|
||||
|
||||
# Flush batch when it reaches BATCH_SIZE
|
||||
if len(buffer) >= BATCH_SIZE:
|
||||
total_batches += 1
|
||||
flush_to_mongo_and_delete(
|
||||
mongo_collection=mongo_collection,
|
||||
buffer=buffer,
|
||||
ids_to_delete=ids_to_delete,
|
||||
model=model,
|
||||
mongo_available=mongo_available,
|
||||
)
|
||||
total_processed += len(buffer)
|
||||
buffer.clear()
|
||||
ids_to_delete.clear()
|
||||
|
||||
# Process final batch if any records remain
|
||||
if buffer:
|
||||
total_batches += 1
|
||||
flush_to_mongo_and_delete(
|
||||
mongo_collection=mongo_collection,
|
||||
buffer=buffer,
|
||||
ids_to_delete=ids_to_delete,
|
||||
model=model,
|
||||
mongo_available=mongo_available,
|
||||
)
|
||||
total_processed += len(buffer)
|
||||
|
||||
logger.info(
|
||||
f"{task_name} cleanup task completed",
|
||||
extra={
|
||||
"total_records_processed": total_processed,
|
||||
"total_batches": total_batches,
|
||||
"mongo_available": mongo_available,
|
||||
"collection_name": collection_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Transform functions for each model
|
||||
def transform_api_log(record: Dict) -> Dict:
|
||||
"""Transform API activity log record."""
|
||||
return {
|
||||
"id": record["id"],
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"token_identifier": record["token_identifier"],
|
||||
"path": record["path"],
|
||||
"method": record["method"],
|
||||
"query_params": record.get("query_params"),
|
||||
"headers": record.get("headers"),
|
||||
"body": record.get("body"),
|
||||
"response_code": record["response_code"],
|
||||
"response_body": record["response_body"],
|
||||
"ip_address": record["ip_address"],
|
||||
"user_agent": record["user_agent"],
|
||||
"created_by_id": record["created_by_id"],
|
||||
}
|
||||
|
||||
|
||||
def transform_email_log(record: Dict) -> Dict:
|
||||
"""Transform email notification log record."""
|
||||
return {
|
||||
"id": record["id"],
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"receiver_id": record["receiver_id"],
|
||||
"triggered_by_id": record["triggered_by_id"],
|
||||
"entity_identifier": record["entity_identifier"],
|
||||
"entity_name": record["entity_name"],
|
||||
"data": record["data"],
|
||||
"processed_at": (
|
||||
str(record["processed_at"]) if record.get("processed_at") else None
|
||||
),
|
||||
"sent_at": str(record["sent_at"]) if record.get("sent_at") else None,
|
||||
"entity": record["entity"],
|
||||
"old_value": record["old_value"],
|
||||
"new_value": record["new_value"],
|
||||
"created_by_id": record["created_by_id"],
|
||||
}
|
||||
|
||||
|
||||
def transform_page_version(record: Dict) -> Dict:
|
||||
"""Transform page version record."""
|
||||
return {
|
||||
"id": record["id"],
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"page_id": record["page_id"],
|
||||
"workspace_id": record["workspace_id"],
|
||||
"owned_by_id": record["owned_by_id"],
|
||||
"description_html": record["description_html"],
|
||||
"description_binary": record["description_binary"],
|
||||
"description_stripped": record["description_stripped"],
|
||||
"description_json": record["description_json"],
|
||||
"sub_pages_data": record["sub_pages_data"],
|
||||
"created_by_id": record["created_by_id"],
|
||||
"updated_by_id": record["updated_by_id"],
|
||||
"deleted_at": str(record["deleted_at"]) if record.get("deleted_at") else None,
|
||||
"last_saved_at": (
|
||||
str(record["last_saved_at"]) if record.get("last_saved_at") else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def transform_issue_description_version(record: Dict) -> Dict:
|
||||
"""Transform issue description version record."""
|
||||
return {
|
||||
"id": record["id"],
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"issue_id": record["issue_id"],
|
||||
"workspace_id": record["workspace_id"],
|
||||
"project_id": record["project_id"],
|
||||
"created_by_id": record["created_by_id"],
|
||||
"updated_by_id": record["updated_by_id"],
|
||||
"owned_by_id": record["owned_by_id"],
|
||||
"last_saved_at": (
|
||||
str(record["last_saved_at"]) if record.get("last_saved_at") else None
|
||||
),
|
||||
"description_binary": record["description_binary"],
|
||||
"description_html": record["description_html"],
|
||||
"description_stripped": record["description_stripped"],
|
||||
"description_json": record["description_json"],
|
||||
"deleted_at": str(record["deleted_at"]) if record.get("deleted_at") else None,
|
||||
}
|
||||
|
||||
|
||||
# Queryset functions for each cleanup task
|
||||
def get_api_logs_queryset():
|
||||
"""Get API logs older than cutoff days."""
|
||||
cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30))
|
||||
cutoff_time = timezone.now() - timedelta(days=cutoff_days)
|
||||
logger.info(f"API logs cutoff time: {cutoff_time}")
|
||||
|
||||
return (
|
||||
APIActivityLog.all_objects.filter(created_at__lte=cutoff_time)
|
||||
.values(
|
||||
"id",
|
||||
"created_at",
|
||||
"token_identifier",
|
||||
"path",
|
||||
"method",
|
||||
"query_params",
|
||||
"headers",
|
||||
"body",
|
||||
"response_code",
|
||||
"response_body",
|
||||
"ip_address",
|
||||
"user_agent",
|
||||
"created_by_id",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
|
||||
def get_email_logs_queryset():
|
||||
"""Get email logs older than cutoff days."""
|
||||
cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30))
|
||||
cutoff_time = timezone.now() - timedelta(days=cutoff_days)
|
||||
logger.info(f"Email logs cutoff time: {cutoff_time}")
|
||||
|
||||
return (
|
||||
EmailNotificationLog.all_objects.filter(sent_at__lte=cutoff_time)
|
||||
.values(
|
||||
"id",
|
||||
"created_at",
|
||||
"receiver_id",
|
||||
"triggered_by_id",
|
||||
"entity_identifier",
|
||||
"entity_name",
|
||||
"data",
|
||||
"processed_at",
|
||||
"sent_at",
|
||||
"entity",
|
||||
"old_value",
|
||||
"new_value",
|
||||
"created_by_id",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
|
||||
def get_page_versions_queryset():
|
||||
"""Get page versions beyond the maximum allowed (20 per page)."""
|
||||
subq = (
|
||||
PageVersion.all_objects.annotate(
|
||||
row_num=Window(
|
||||
expression=RowNumber(),
|
||||
partition_by=[F("page_id")],
|
||||
order_by=F("created_at").desc(),
|
||||
)
|
||||
)
|
||||
.filter(row_num__gt=20)
|
||||
.values("id")
|
||||
)
|
||||
|
||||
return (
|
||||
PageVersion.all_objects.filter(id__in=Subquery(subq))
|
||||
.values(
|
||||
"id",
|
||||
"created_at",
|
||||
"page_id",
|
||||
"workspace_id",
|
||||
"owned_by_id",
|
||||
"description_html",
|
||||
"description_binary",
|
||||
"description_stripped",
|
||||
"description_json",
|
||||
"sub_pages_data",
|
||||
"created_by_id",
|
||||
"updated_by_id",
|
||||
"deleted_at",
|
||||
"last_saved_at",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
|
||||
def get_issue_description_versions_queryset():
|
||||
"""Get issue description versions beyond the maximum allowed (20 per issue)."""
|
||||
subq = (
|
||||
IssueDescriptionVersion.all_objects.annotate(
|
||||
row_num=Window(
|
||||
expression=RowNumber(),
|
||||
partition_by=[F("issue_id")],
|
||||
order_by=F("created_at").desc(),
|
||||
)
|
||||
)
|
||||
.filter(row_num__gt=20)
|
||||
.values("id")
|
||||
)
|
||||
|
||||
return (
|
||||
IssueDescriptionVersion.all_objects.filter(id__in=Subquery(subq))
|
||||
.values(
|
||||
"id",
|
||||
"created_at",
|
||||
"issue_id",
|
||||
"workspace_id",
|
||||
"project_id",
|
||||
"created_by_id",
|
||||
"updated_by_id",
|
||||
"owned_by_id",
|
||||
"last_saved_at",
|
||||
"description_binary",
|
||||
"description_html",
|
||||
"description_stripped",
|
||||
"description_json",
|
||||
"deleted_at",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
|
||||
# Celery tasks - now much simpler!
|
||||
@shared_task
|
||||
def delete_api_logs():
|
||||
"""Delete old API activity logs."""
|
||||
process_cleanup_task(
|
||||
queryset_func=get_api_logs_queryset,
|
||||
transform_func=transform_api_log,
|
||||
model=APIActivityLog,
|
||||
task_name="API Activity Log",
|
||||
collection_name="api_activity_logs",
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_email_notification_logs():
|
||||
"""Delete old email notification logs."""
|
||||
process_cleanup_task(
|
||||
queryset_func=get_email_logs_queryset,
|
||||
transform_func=transform_email_log,
|
||||
model=EmailNotificationLog,
|
||||
task_name="Email Notification Log",
|
||||
collection_name="email_notification_logs",
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_page_versions():
|
||||
"""Delete excess page versions."""
|
||||
process_cleanup_task(
|
||||
queryset_func=get_page_versions_queryset,
|
||||
transform_func=transform_page_version,
|
||||
model=PageVersion,
|
||||
task_name="Page Version",
|
||||
collection_name="page_versions",
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_issue_description_versions():
|
||||
"""Delete excess issue description versions."""
|
||||
process_cleanup_task(
|
||||
queryset_func=get_issue_description_versions_queryset,
|
||||
transform_func=transform_issue_description_version,
|
||||
model=IssueDescriptionVersion,
|
||||
task_name="Issue Description Version",
|
||||
collection_name="issue_description_versions",
|
||||
)
|
||||
@@ -50,9 +50,21 @@ app.conf.beat_schedule = {
|
||||
"schedule": crontab(hour=2, minute=0), # UTC 02:00
|
||||
},
|
||||
"check-every-day-to-delete-api-logs": {
|
||||
"task": "plane.bgtasks.api_logs_task.delete_api_logs",
|
||||
"task": "plane.bgtasks.cleanup_task.delete_api_logs",
|
||||
"schedule": crontab(hour=2, minute=30), # UTC 02:30
|
||||
},
|
||||
"check-every-day-to-delete-email-notification-logs": {
|
||||
"task": "plane.bgtasks.cleanup_task.delete_email_notification_logs",
|
||||
"schedule": crontab(hour=3, minute=0), # UTC 03:00
|
||||
},
|
||||
"check-every-day-to-delete-page-versions": {
|
||||
"task": "plane.bgtasks.cleanup_task.delete_page_versions",
|
||||
"schedule": crontab(hour=3, minute=30), # UTC 03:30
|
||||
},
|
||||
"check-every-day-to-delete-issue-description-versions": {
|
||||
"task": "plane.bgtasks.cleanup_task.delete_issue_description_versions",
|
||||
"schedule": crontab(hour=4, minute=0), # UTC 04:00
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# Generated by Django 4.2.21 on 2025-08-19 11:52
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0100_profile_has_marketing_email_consent_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Description",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"deleted_at",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="Deleted At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("description_json", models.JSONField(blank=True, default=dict)),
|
||||
("description_html", models.TextField(blank=True, default="<p></p>")),
|
||||
("description_binary", models.BinaryField(null=True)),
|
||||
("description_stripped", models.TextField(blank=True, null=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="project_%(class)s",
|
||||
to="db.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="workspace_%(class)s",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Description",
|
||||
"verbose_name_plural": "Descriptions",
|
||||
"db_table": "descriptions",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="DescriptionVersion",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"deleted_at",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="Deleted At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("description_json", models.JSONField(blank=True, default=dict)),
|
||||
("description_html", models.TextField(blank=True, default="<p></p>")),
|
||||
("description_binary", models.BinaryField(null=True)),
|
||||
("description_stripped", models.TextField(blank=True, null=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="versions",
|
||||
to="db.description",
|
||||
),
|
||||
),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="project_%(class)s",
|
||||
to="db.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="workspace_%(class)s",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Description Version",
|
||||
"verbose_name_plural": "Description Versions",
|
||||
"db_table": "description_versions",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -83,3 +83,5 @@ from .label import Label
|
||||
from .device import Device, DeviceSession
|
||||
|
||||
from .sticky import Sticky
|
||||
|
||||
from .description import Description, DescriptionVersion
|
||||
@@ -0,0 +1,56 @@
|
||||
from django.db import models
|
||||
from django.utils.html import strip_tags
|
||||
from .workspace import WorkspaceBaseModel
|
||||
|
||||
|
||||
class Description(WorkspaceBaseModel):
|
||||
|
||||
|
||||
description_json = models.JSONField(default=dict, blank=True)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_binary = models.BinaryField(null=True)
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Description"
|
||||
verbose_name_plural = "Descriptions"
|
||||
db_table = "descriptions"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
super(Description, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
class DescriptionVersion(WorkspaceBaseModel):
|
||||
"""
|
||||
DescriptionVersion is a model used to store historical versions of a Description.
|
||||
"""
|
||||
|
||||
description = models.ForeignKey(
|
||||
"db.Description", on_delete=models.CASCADE, related_name="versions"
|
||||
)
|
||||
description_json = models.JSONField(default=dict, blank=True)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_binary = models.BinaryField(null=True)
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Description Version"
|
||||
verbose_name_plural = "Description Versions"
|
||||
db_table = "description_versions"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
super(DescriptionVersion, self).save(*args, **kwargs)
|
||||
@@ -2,11 +2,12 @@
|
||||
import json
|
||||
import secrets
|
||||
import os
|
||||
import requests
|
||||
|
||||
# Django imports
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
# Module imports
|
||||
from plane.license.models import Instance, InstanceEdition
|
||||
@@ -20,21 +21,38 @@ class Command(BaseCommand):
|
||||
# Positional argument
|
||||
parser.add_argument("machine_signature", type=str, help="Machine signature")
|
||||
|
||||
def read_package_json(self):
|
||||
with open("package.json", "r") as file:
|
||||
# Load JSON content from the file
|
||||
data = json.load(file)
|
||||
def check_for_current_version(self):
|
||||
if os.environ.get("APP_VERSION", False):
|
||||
return os.environ.get("APP_VERSION")
|
||||
|
||||
payload = {
|
||||
"instance_key": settings.INSTANCE_KEY,
|
||||
"version": data.get("version", 0.1),
|
||||
}
|
||||
return payload
|
||||
try:
|
||||
with open("package.json", "r") as file:
|
||||
data = json.load(file)
|
||||
return data.get("version", "v0.1.0")
|
||||
except Exception:
|
||||
self.stdout.write("Error checking for current version")
|
||||
return "v0.1.0"
|
||||
|
||||
def check_for_latest_version(self, fallback_version):
|
||||
try:
|
||||
response = requests.get(
|
||||
"https://api.github.com/repos/makeplane/plane/releases/latest",
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data.get("tag_name", fallback_version)
|
||||
except Exception:
|
||||
self.stdout.write("Error checking for latest version")
|
||||
return fallback_version
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Check if the instance is registered
|
||||
instance = Instance.objects.first()
|
||||
|
||||
current_version = self.check_for_current_version()
|
||||
latest_version = self.check_for_latest_version(current_version)
|
||||
|
||||
# If instance is None then register this instance
|
||||
if instance is None:
|
||||
machine_signature = options.get("machine_signature", "machine-signature")
|
||||
@@ -42,13 +60,11 @@ class Command(BaseCommand):
|
||||
if not machine_signature:
|
||||
raise CommandError("Machine signature is required")
|
||||
|
||||
payload = self.read_package_json()
|
||||
|
||||
instance = Instance.objects.create(
|
||||
instance_name="Plane Community Edition",
|
||||
instance_id=secrets.token_hex(12),
|
||||
current_version=payload.get("version"),
|
||||
latest_version=payload.get("version"),
|
||||
current_version=current_version,
|
||||
latest_version=latest_version,
|
||||
last_checked_at=timezone.now(),
|
||||
is_test=os.environ.get("IS_TEST", "0") == "1",
|
||||
edition=InstanceEdition.PLANE_COMMUNITY.value,
|
||||
@@ -57,11 +73,11 @@ class Command(BaseCommand):
|
||||
self.stdout.write(self.style.SUCCESS("Instance registered"))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS("Instance already registered"))
|
||||
payload = self.read_package_json()
|
||||
|
||||
# Update the instance details
|
||||
instance.last_checked_at = timezone.now()
|
||||
instance.current_version = payload.get("version")
|
||||
instance.latest_version = payload.get("version")
|
||||
instance.current_version = current_version
|
||||
instance.latest_version = latest_version
|
||||
instance.is_test = os.environ.get("IS_TEST", "0") == "1"
|
||||
instance.edition = InstanceEdition.PLANE_COMMUNITY.value
|
||||
instance.save()
|
||||
|
||||
@@ -284,7 +284,7 @@ CELERY_IMPORTS = (
|
||||
"plane.bgtasks.exporter_expired_task",
|
||||
"plane.bgtasks.file_asset_task",
|
||||
"plane.bgtasks.email_notification_task",
|
||||
"plane.bgtasks.api_logs_task",
|
||||
"plane.bgtasks.cleanup_task",
|
||||
"plane.license.bgtasks.tracer",
|
||||
# management tasks
|
||||
"plane.bgtasks.dummy_data_task",
|
||||
@@ -304,16 +304,10 @@ GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
|
||||
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
|
||||
|
||||
|
||||
# Posthog settings
|
||||
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False)
|
||||
POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False)
|
||||
|
||||
# instance key
|
||||
INSTANCE_KEY = os.environ.get(
|
||||
"INSTANCE_KEY", "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3"
|
||||
)
|
||||
|
||||
# Skip environment variable configuration
|
||||
SKIP_ENV_VAR = os.environ.get("SKIP_ENV_VAR", "1") == "1"
|
||||
|
||||
|
||||
@@ -73,5 +73,10 @@ LOGGING = {
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.mongo": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from pymongo import MongoClient
|
||||
from pymongo.database import Database
|
||||
from pymongo.collection import Collection
|
||||
from typing import Optional, TypeVar, Type
|
||||
|
||||
|
||||
T = TypeVar("T", bound="MongoConnection")
|
||||
|
||||
# Set up logger
|
||||
logger = logging.getLogger("plane.mongo")
|
||||
|
||||
|
||||
class MongoConnection:
|
||||
"""
|
||||
A singleton class that manages MongoDB connections.
|
||||
|
||||
This class ensures only one MongoDB connection is maintained throughout the application.
|
||||
It provides methods to access the MongoDB client, database, and collections.
|
||||
|
||||
Attributes:
|
||||
_instance (Optional[MongoConnection]): The singleton instance of this class
|
||||
_client (Optional[MongoClient]): The MongoDB client instance
|
||||
_db (Optional[Database]): The MongoDB database instance
|
||||
"""
|
||||
|
||||
_instance: Optional["MongoConnection"] = None
|
||||
_client: Optional[MongoClient] = None
|
||||
_db: Optional[Database] = None
|
||||
|
||||
def __new__(cls: Type[T]) -> T:
|
||||
"""
|
||||
Creates a new instance of MongoConnection if one doesn't exist.
|
||||
|
||||
Returns:
|
||||
MongoConnection: The singleton instance
|
||||
"""
|
||||
if cls._instance is None:
|
||||
cls._instance = super(MongoConnection, cls).__new__(cls)
|
||||
try:
|
||||
mongo_url = getattr(settings, "MONGO_DB_URL", None)
|
||||
mongo_db_database = getattr(settings, "MONGO_DB_DATABASE", None)
|
||||
|
||||
if not mongo_url or not mongo_db_database:
|
||||
logger.warning(
|
||||
"MongoDB connection parameters not configured. MongoDB functionality will be disabled."
|
||||
)
|
||||
return cls._instance
|
||||
|
||||
cls._client = MongoClient(mongo_url)
|
||||
cls._db = cls._client[mongo_db_database]
|
||||
|
||||
# Test the connection
|
||||
cls._client.server_info()
|
||||
logger.info("MongoDB connection established successfully")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to initialize MongoDB connection: {str(e)}. MongoDB functionality will be disabled."
|
||||
)
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def get_client(cls) -> Optional[MongoClient]:
|
||||
"""
|
||||
Returns the MongoDB client instance.
|
||||
|
||||
Returns:
|
||||
Optional[MongoClient]: The MongoDB client instance or None if not configured
|
||||
"""
|
||||
if cls._client is None:
|
||||
cls._instance = cls()
|
||||
return cls._client
|
||||
|
||||
@classmethod
|
||||
def get_db(cls) -> Optional[Database]:
|
||||
"""
|
||||
Returns the MongoDB database instance.
|
||||
|
||||
Returns:
|
||||
Optional[Database]: The MongoDB database instance or None if not configured
|
||||
"""
|
||||
if cls._db is None:
|
||||
cls._instance = cls()
|
||||
return cls._db
|
||||
|
||||
@classmethod
|
||||
def get_collection(cls, collection_name: str) -> Optional[Collection]:
|
||||
"""
|
||||
Returns a MongoDB collection by name.
|
||||
|
||||
Args:
|
||||
collection_name (str): The name of the collection to retrieve
|
||||
|
||||
Returns:
|
||||
Optional[Collection]: The MongoDB collection instance or None if not configured
|
||||
"""
|
||||
try:
|
||||
db = cls.get_db()
|
||||
if db is None:
|
||||
logger.warning(
|
||||
f"Cannot access collection '{collection_name}': MongoDB not configured"
|
||||
)
|
||||
return None
|
||||
return db[collection_name]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to access collection '{collection_name}': {str(e)}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def is_configured(cls) -> bool:
|
||||
"""
|
||||
Check if MongoDB is properly configured and connected.
|
||||
|
||||
Returns:
|
||||
bool: True if MongoDB is configured and connected, False otherwise
|
||||
"""
|
||||
return cls._client is not None and cls._db is not None
|
||||
@@ -83,5 +83,10 @@ LOGGING = {
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.mongo": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ from plane.db.models import (
|
||||
)
|
||||
from plane.utils.content_validator import (
|
||||
validate_html_content,
|
||||
validate_json_content,
|
||||
validate_binary_data,
|
||||
)
|
||||
|
||||
@@ -290,20 +289,22 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
raise serializers.ValidationError("Start date cannot exceed target date")
|
||||
|
||||
# Validate description content for security
|
||||
if "description" in data and data["description"]:
|
||||
is_valid, error_msg = validate_json_content(data["description"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description": error_msg})
|
||||
|
||||
if "description_html" in data and data["description_html"]:
|
||||
is_valid, error_msg = validate_html_content(data["description_html"])
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(
|
||||
data["description_html"]
|
||||
)
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_html": error_msg})
|
||||
raise serializers.ValidationError(
|
||||
{"error": "html content is not valid"}
|
||||
)
|
||||
# Update the data with sanitized HTML if available
|
||||
if sanitized_html is not None:
|
||||
data["description_html"] = sanitized_html
|
||||
|
||||
if "description_binary" in data and data["description_binary"]:
|
||||
is_valid, error_msg = validate_binary_data(data["description_binary"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_binary": error_msg})
|
||||
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -1,36 +1,11 @@
|
||||
# Python imports
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
|
||||
import nh3
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
# Maximum allowed size for binary data (10MB)
|
||||
MAX_SIZE = 10 * 1024 * 1024
|
||||
|
||||
# Maximum recursion depth to prevent stack overflow
|
||||
MAX_RECURSION_DEPTH = 20
|
||||
|
||||
# Dangerous text patterns that could indicate XSS or script injection
|
||||
DANGEROUS_TEXT_PATTERNS = [
|
||||
r"<script[^>]*>.*?</script>",
|
||||
r"javascript\s*:",
|
||||
r"data\s*:\s*text/html",
|
||||
r"eval\s*\(",
|
||||
r"document\s*\.",
|
||||
r"window\s*\.",
|
||||
r"location\s*\.",
|
||||
]
|
||||
|
||||
# Dangerous attribute patterns for HTML attributes
|
||||
DANGEROUS_ATTR_PATTERNS = [
|
||||
r"javascript\s*:",
|
||||
r"data\s*:\s*text/html",
|
||||
r"eval\s*\(",
|
||||
r"alert\s*\(",
|
||||
r"document\s*\.",
|
||||
r"window\s*\.",
|
||||
]
|
||||
|
||||
# Suspicious patterns for binary data content
|
||||
SUSPICIOUS_BINARY_PATTERNS = [
|
||||
"<html",
|
||||
@@ -41,70 +16,6 @@ SUSPICIOUS_BINARY_PATTERNS = [
|
||||
"<iframe",
|
||||
]
|
||||
|
||||
# Malicious HTML patterns for content validation
|
||||
MALICIOUS_HTML_PATTERNS = [
|
||||
# Script tags with any content
|
||||
r"<script[^>]*>",
|
||||
r"</script>",
|
||||
# JavaScript URLs in various attributes
|
||||
r'(?:href|src|action)\s*=\s*["\']?\s*javascript:',
|
||||
# Data URLs with text/html (potential XSS)
|
||||
r'(?:href|src|action)\s*=\s*["\']?\s*data:text/html',
|
||||
# Dangerous event handlers with JavaScript-like content
|
||||
r'on(?:load|error|click|focus|blur|change|submit|reset|select|resize|scroll|unload|beforeunload|hashchange|popstate|storage|message|offline|online)\s*=\s*["\']?[^"\']*(?:javascript|alert|eval|document\.|window\.|location\.|history\.)[^"\']*["\']?',
|
||||
# Object and embed tags that could load external content
|
||||
r"<(?:object|embed)[^>]*(?:data|src)\s*=",
|
||||
# Base tag that could change relative URL resolution
|
||||
r"<base[^>]*href\s*=",
|
||||
# Dangerous iframe sources
|
||||
r'<iframe[^>]*src\s*=\s*["\']?(?:javascript:|data:text/html)',
|
||||
# Meta refresh redirects
|
||||
r'<meta[^>]*http-equiv\s*=\s*["\']?refresh["\']?',
|
||||
# Link tags - simplified patterns
|
||||
r'<link[^>]*rel\s*=\s*["\']?stylesheet["\']?',
|
||||
r'<link[^>]*href\s*=\s*["\']?https?://',
|
||||
r'<link[^>]*href\s*=\s*["\']?//',
|
||||
r'<link[^>]*href\s*=\s*["\']?(?:data:|javascript:)',
|
||||
# Style tags with external imports
|
||||
r"<style[^>]*>.*?@import.*?(?:https?://|//)",
|
||||
# Link tags with dangerous rel types
|
||||
r'<link[^>]*rel\s*=\s*["\']?(?:import|preload|prefetch|dns-prefetch|preconnect)["\']?',
|
||||
# Forms with action attributes
|
||||
r"<form[^>]*action\s*=",
|
||||
]
|
||||
|
||||
# Dangerous JavaScript patterns for event handlers
|
||||
DANGEROUS_JS_PATTERNS = [
|
||||
r"alert\s*\(",
|
||||
r"eval\s*\(",
|
||||
r"document\s*\.",
|
||||
r"window\s*\.",
|
||||
r"location\s*\.",
|
||||
r"fetch\s*\(",
|
||||
r"XMLHttpRequest",
|
||||
r"innerHTML\s*=",
|
||||
r"outerHTML\s*=",
|
||||
r"document\.write",
|
||||
r"script\s*>",
|
||||
]
|
||||
|
||||
# HTML self-closing tags that don't need closing tags
|
||||
SELF_CLOSING_TAGS = {
|
||||
"img",
|
||||
"br",
|
||||
"hr",
|
||||
"input",
|
||||
"meta",
|
||||
"link",
|
||||
"area",
|
||||
"base",
|
||||
"col",
|
||||
"embed",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
}
|
||||
|
||||
|
||||
def validate_binary_data(data):
|
||||
"""
|
||||
@@ -149,209 +60,21 @@ def validate_binary_data(data):
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_html_content(html_content):
|
||||
def validate_html_content(html_content: str):
|
||||
"""
|
||||
Validate that HTML content is safe and doesn't contain malicious patterns.
|
||||
|
||||
Args:
|
||||
html_content (str): The HTML content to validate
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid: bool, error_message: str or None)
|
||||
Sanitize HTML content using nh3.
|
||||
Returns a tuple: (is_valid, error_message, clean_html)
|
||||
"""
|
||||
if not html_content:
|
||||
return True, None # Empty is OK
|
||||
return True, None, None
|
||||
|
||||
# Size check - 10MB limit (consistent with binary validation)
|
||||
if len(html_content.encode("utf-8")) > MAX_SIZE:
|
||||
return False, "HTML content exceeds maximum size limit (10MB)"
|
||||
|
||||
# Check for specific malicious patterns (simplified and more reliable)
|
||||
for pattern in MALICIOUS_HTML_PATTERNS:
|
||||
if re.search(pattern, html_content, re.IGNORECASE | re.DOTALL):
|
||||
return (
|
||||
False,
|
||||
f"HTML content contains potentially malicious patterns: {pattern}",
|
||||
)
|
||||
|
||||
# Additional check for inline event handlers that contain suspicious content
|
||||
# This is more permissive - only blocks if the event handler contains actual dangerous code
|
||||
event_handler_pattern = r'on\w+\s*=\s*["\']([^"\']*)["\']'
|
||||
event_matches = re.findall(event_handler_pattern, html_content, re.IGNORECASE)
|
||||
|
||||
for handler_content in event_matches:
|
||||
for js_pattern in DANGEROUS_JS_PATTERNS:
|
||||
if re.search(js_pattern, handler_content, re.IGNORECASE):
|
||||
return (
|
||||
False,
|
||||
f"HTML content contains dangerous JavaScript in event handler: {handler_content[:100]}",
|
||||
)
|
||||
|
||||
# Basic HTML structure validation - check for common malformed tags
|
||||
try:
|
||||
# Count opening and closing tags for basic structure validation
|
||||
opening_tags = re.findall(r"<(\w+)[^>]*>", html_content)
|
||||
closing_tags = re.findall(r"</(\w+)>", html_content)
|
||||
|
||||
# Filter out self-closing tags from opening tags
|
||||
opening_tags_filtered = [
|
||||
tag for tag in opening_tags if tag.lower() not in SELF_CLOSING_TAGS
|
||||
]
|
||||
|
||||
# Basic check - if we have significantly more opening than closing tags, it might be malformed
|
||||
if len(opening_tags_filtered) > len(closing_tags) + 10: # Allow some tolerance
|
||||
return False, "HTML content appears to be malformed (unmatched tags)"
|
||||
|
||||
except Exception:
|
||||
# If HTML parsing fails, we'll allow it
|
||||
pass
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_json_content(json_content):
|
||||
"""
|
||||
Validate that JSON content is safe and doesn't contain malicious patterns.
|
||||
|
||||
Args:
|
||||
json_content (dict): The JSON content to validate
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid: bool, error_message: str or None)
|
||||
"""
|
||||
if not json_content:
|
||||
return True, None # Empty is OK
|
||||
return False, "HTML content exceeds maximum size limit (10MB)", None
|
||||
|
||||
try:
|
||||
# Size check - 10MB limit (consistent with other validations)
|
||||
json_str = json.dumps(json_content)
|
||||
if len(json_str.encode("utf-8")) > MAX_SIZE:
|
||||
return False, "JSON content exceeds maximum size limit (10MB)"
|
||||
|
||||
# Basic structure validation for page description JSON
|
||||
if isinstance(json_content, dict):
|
||||
# Check for expected page description structure
|
||||
# This is based on ProseMirror/Tiptap JSON structure
|
||||
if "type" in json_content and json_content.get("type") == "doc":
|
||||
# Valid document structure
|
||||
if "content" in json_content and isinstance(
|
||||
json_content["content"], list
|
||||
):
|
||||
# Recursively check content for suspicious patterns
|
||||
is_valid, error_msg = _validate_json_content_array(
|
||||
json_content["content"]
|
||||
)
|
||||
if not is_valid:
|
||||
return False, error_msg
|
||||
elif "type" not in json_content and "content" not in json_content:
|
||||
# Allow other JSON structures but validate for suspicious content
|
||||
is_valid, error_msg = _validate_json_content_recursive(json_content)
|
||||
if not is_valid:
|
||||
return False, error_msg
|
||||
else:
|
||||
return False, "JSON description must be a valid object"
|
||||
|
||||
except (TypeError, ValueError) as e:
|
||||
return False, "Invalid JSON structure"
|
||||
clean_html = nh3.clean(html_content)
|
||||
return True, None, clean_html
|
||||
except Exception as e:
|
||||
return False, "Failed to validate JSON content"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def _validate_json_content_array(content, depth=0):
|
||||
"""
|
||||
Validate JSON content array for suspicious patterns.
|
||||
|
||||
Args:
|
||||
content (list): Array of content nodes to validate
|
||||
depth (int): Current recursion depth (default: 0)
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid: bool, error_message: str or None)
|
||||
"""
|
||||
# Check recursion depth to prevent stack overflow
|
||||
if depth > MAX_RECURSION_DEPTH:
|
||||
return False, f"Maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded"
|
||||
|
||||
if not isinstance(content, list):
|
||||
return True, None
|
||||
|
||||
for node in content:
|
||||
if isinstance(node, dict):
|
||||
# Check text content for suspicious patterns (more targeted)
|
||||
if node.get("type") == "text" and "text" in node:
|
||||
text_content = node["text"]
|
||||
for pattern in DANGEROUS_TEXT_PATTERNS:
|
||||
if re.search(pattern, text_content, re.IGNORECASE):
|
||||
return (
|
||||
False,
|
||||
"JSON content contains suspicious script patterns in text",
|
||||
)
|
||||
|
||||
# Check attributes for suspicious content (more targeted)
|
||||
if "attrs" in node and isinstance(node["attrs"], dict):
|
||||
for attr_name, attr_value in node["attrs"].items():
|
||||
if isinstance(attr_value, str):
|
||||
# Only check specific attributes that could be dangerous
|
||||
if attr_name.lower() in [
|
||||
"href",
|
||||
"src",
|
||||
"action",
|
||||
"onclick",
|
||||
"onload",
|
||||
"onerror",
|
||||
]:
|
||||
for pattern in DANGEROUS_ATTR_PATTERNS:
|
||||
if re.search(pattern, attr_value, re.IGNORECASE):
|
||||
return (
|
||||
False,
|
||||
f"JSON content contains dangerous pattern in {attr_name} attribute",
|
||||
)
|
||||
|
||||
# Recursively check nested content
|
||||
if "content" in node and isinstance(node["content"], list):
|
||||
is_valid, error_msg = _validate_json_content_array(
|
||||
node["content"], depth + 1
|
||||
)
|
||||
if not is_valid:
|
||||
return False, error_msg
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def _validate_json_content_recursive(obj, depth=0):
|
||||
"""
|
||||
Recursively validate JSON object for suspicious content.
|
||||
|
||||
Args:
|
||||
obj: JSON object (dict, list, or primitive) to validate
|
||||
depth (int): Current recursion depth (default: 0)
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid: bool, error_message: str or None)
|
||||
"""
|
||||
# Check recursion depth to prevent stack overflow
|
||||
if depth > MAX_RECURSION_DEPTH:
|
||||
return False, f"Maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded"
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
if isinstance(value, str):
|
||||
# Check for dangerous patterns using module constants
|
||||
for pattern in DANGEROUS_TEXT_PATTERNS:
|
||||
if re.search(pattern, value, re.IGNORECASE):
|
||||
return (
|
||||
False,
|
||||
"JSON content contains suspicious script patterns",
|
||||
)
|
||||
elif isinstance(value, (dict, list)):
|
||||
is_valid, error_msg = _validate_json_content_recursive(value, depth + 1)
|
||||
if not is_valid:
|
||||
return False, error_msg
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
is_valid, error_msg = _validate_json_content_recursive(item, depth + 1)
|
||||
if not is_valid:
|
||||
return False, error_msg
|
||||
|
||||
return True, None
|
||||
log_exception(e)
|
||||
return False, "Failed to sanitize HTML", None
|
||||
|
||||
@@ -160,7 +160,7 @@ class OffsetPaginator:
|
||||
total_count = (
|
||||
self.total_count_queryset.count()
|
||||
if self.total_count_queryset
|
||||
else results.count()
|
||||
else queryset.count()
|
||||
)
|
||||
|
||||
# Check if there are more results available after the current page
|
||||
|
||||
@@ -9,6 +9,8 @@ psycopg==3.1.18
|
||||
psycopg-binary==3.1.18
|
||||
psycopg-c==3.1.18
|
||||
dj-database-url==2.1.0
|
||||
# mongo
|
||||
pymongo==4.6.3
|
||||
# redis
|
||||
redis==5.0.4
|
||||
django-redis==5.4.0
|
||||
@@ -66,4 +68,6 @@ opentelemetry-sdk==1.28.1
|
||||
opentelemetry-instrumentation-django==0.49b1
|
||||
opentelemetry-exporter-otlp==1.28.1
|
||||
# OpenAPI Specification
|
||||
drf-spectacular==0.28.0
|
||||
drf-spectacular==0.28.0
|
||||
# html sanitizer
|
||||
nh3==0.2.18
|
||||
|
||||
@@ -4,12 +4,12 @@ RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
RUN yarn global add turbo
|
||||
RUN yarn install
|
||||
RUN corepack enable pnpm && pnpm add -g turbo
|
||||
RUN pnpm install
|
||||
EXPOSE 3003
|
||||
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
VOLUME [ "/app/node_modules", "/app/live/node_modules"]
|
||||
|
||||
CMD ["yarn","dev", "--filter=live"]
|
||||
CMD ["pnpm","dev", "--filter=live"]
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
# Setup pnpm package manager with corepack and configure global bin directory for caching
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 1: Prune the project
|
||||
# *****************************************************************************
|
||||
@@ -9,9 +15,10 @@ RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
RUN yarn global add turbo
|
||||
ARG TURBO_VERSION=2.5.6
|
||||
RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION}
|
||||
COPY . .
|
||||
RUN turbo prune live --docker
|
||||
RUN turbo prune --scope=live --docker
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 2: Install dependencies & build the project
|
||||
@@ -25,16 +32,18 @@ WORKDIR /app
|
||||
# First install dependencies (as they change less often)
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/yarn.lock ./yarn.lock
|
||||
RUN yarn install
|
||||
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN corepack enable pnpm
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
|
||||
|
||||
# Build the project and its dependencies
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store
|
||||
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN yarn turbo build --filter=live
|
||||
RUN pnpm turbo run build --filter=live
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 3: Run the project
|
||||
@@ -44,11 +53,12 @@ FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=installer /app/packages ./packages
|
||||
COPY --from=installer /app/apps/live/dist ./live
|
||||
COPY --from=installer /app/apps/live/dist ./apps/live/dist
|
||||
COPY --from=installer /app/apps/live/node_modules ./apps/live/node_modules
|
||||
COPY --from=installer /app/node_modules ./node_modules
|
||||
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "live/server.js"]
|
||||
CMD ["node", "apps/live/dist/server.js"]
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
"@hocuspocus/extension-logger": "^2.15.0",
|
||||
"@hocuspocus/extension-redis": "^2.15.0",
|
||||
"@hocuspocus/server": "^2.15.0",
|
||||
"@plane/editor": "*",
|
||||
"@plane/types": "*",
|
||||
"@plane/editor": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"@tiptap/core": "^2.22.3",
|
||||
"@tiptap/html": "^2.22.3",
|
||||
"axios": "1.11.0",
|
||||
@@ -46,15 +46,15 @@
|
||||
"yjs": "^13.6.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
"@plane/typescript-config": "*",
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/compression": "1.8.1",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/dotenv": "^8.2.0",
|
||||
"@types/express": "^4.17.23",
|
||||
"@types/express-ws": "^3.0.5",
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/pino-http": "^5.8.4",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"concurrently": "^9.0.1",
|
||||
"nodemon": "^3.1.7",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { pinoHttp } from "pino-http";
|
||||
import { Logger } from "pino";
|
||||
|
||||
const transport = {
|
||||
target: "pino-pretty",
|
||||
@@ -37,4 +36,4 @@ export const logger = pinoHttp({
|
||||
},
|
||||
});
|
||||
|
||||
export const manualLogger: Logger = logger.logger;
|
||||
export const manualLogger: typeof logger.logger = logger.logger;
|
||||
|
||||
@@ -14,6 +14,7 @@ export abstract class APIService {
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL,
|
||||
withCredentials: true,
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,6 @@
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
.vercel
|
||||
.tubro
|
||||
out/
|
||||
dis/
|
||||
build/
|
||||
dist/
|
||||
build/
|
||||
node_modules/
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
FROM node:22-alpine
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN yarn global add turbo
|
||||
RUN yarn install
|
||||
|
||||
EXPOSE 4000
|
||||
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
|
||||
|
||||
VOLUME [ "/app/node_modules", "/app/space/node_modules"]
|
||||
CMD ["yarn","dev", "--filter=space"]
|
||||
@@ -1,94 +0,0 @@
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 1: Build the project
|
||||
# *****************************************************************************
|
||||
FROM base AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
RUN yarn global add turbo
|
||||
COPY . .
|
||||
|
||||
RUN turbo prune --scope=space --docker
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 2: Install dependencies & build the project
|
||||
# *****************************************************************************
|
||||
FROM base AS installer
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/yarn.lock ./yarn.lock
|
||||
RUN yarn install --network-timeout 500000
|
||||
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_WEB_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN yarn turbo run build --filter=space
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 3: Copy the project and start it
|
||||
# *****************************************************************************
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
USER nextjs
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=installer /app/apps/space/.next/standalone ./
|
||||
COPY --from=installer /app/apps/space/.next/static ./apps/space/.next/static
|
||||
COPY --from=installer /app/apps/space/public ./apps/space/public
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_WEB_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "apps/space/server.js"]
|
||||
@@ -3,11 +3,13 @@
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { LogoSpinner, PoweredBy } from "@/components/common";
|
||||
import { IssuesNavbarRoot } from "@/components/issues";
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { PoweredBy } from "@/components/common/powered-by";
|
||||
import { SomethingWentWrongError } from "@/components/issues/issue-layouts/error";
|
||||
import { IssuesNavbarRoot } from "@/components/issues/navbar";
|
||||
// hooks
|
||||
import { useIssueFilter, usePublish, usePublishList } from "@/hooks/store";
|
||||
import { usePublish, usePublishList } from "@/hooks/store/publish";
|
||||
import { useIssueFilter } from "@/hooks/store/use-issue-filter";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
|
||||
@@ -4,9 +4,11 @@ import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { IssuesLayoutsRoot } from "@/components/issues";
|
||||
import { IssuesLayoutsRoot } from "@/components/issues/issue-layouts";
|
||||
// hooks
|
||||
import { usePublish, useLabel, useStates } from "@/hooks/store";
|
||||
import { usePublish } from "@/hooks/store/publish";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useStates } from "@/hooks/store/use-state";
|
||||
|
||||
type Props = {
|
||||
params: {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { UserLoggedIn } from "@/components/account";
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
import { UserLoggedIn } from "@/components/account/user-logged-in";
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { AuthView } from "@/components/views";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
import { useUser } from "@/hooks/store/use-user";
|
||||
|
||||
const HomePage = observer(() => {
|
||||
const { data: currentUser, isAuthenticated, isInitializing } = useUser();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { FC, ReactNode } from "react";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
// components
|
||||
import { TranslationProvider } from "@plane/i18n";
|
||||
import { InstanceProvider } from "@/lib/instance-provider";
|
||||
@@ -15,12 +16,14 @@ export const AppProvider: FC<IAppProvider> = (props) => {
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<StoreProvider>
|
||||
<TranslationProvider>
|
||||
<ToastProvider>
|
||||
<InstanceProvider>{children}</InstanceProvider>
|
||||
</ToastProvider>
|
||||
</TranslationProvider>
|
||||
</StoreProvider>
|
||||
<ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
|
||||
<StoreProvider>
|
||||
<TranslationProvider>
|
||||
<ToastProvider>
|
||||
<InstanceProvider>{children}</InstanceProvider>
|
||||
</ToastProvider>
|
||||
</TranslationProvider>
|
||||
</StoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { LogoSpinner, PoweredBy } from "@/components/common";
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { PoweredBy } from "@/components/common/powered-by";
|
||||
import { SomethingWentWrongError } from "@/components/issues/issue-layouts/error";
|
||||
// hooks
|
||||
import { usePublish, usePublishList } from "@/hooks/store";
|
||||
import { usePublish, usePublishList } from "@/hooks/store/publish";
|
||||
// Plane web
|
||||
import { ViewNavbarRoot } from "@/plane-web/components/navbar";
|
||||
import { useView } from "@/plane-web/hooks/store";
|
||||
@@ -18,7 +19,7 @@ type Props = {
|
||||
};
|
||||
};
|
||||
|
||||
const IssuesLayout = observer((props: Props) => {
|
||||
const ViewsLayout = observer((props: Props) => {
|
||||
const { children, params } = props;
|
||||
// params
|
||||
const { anchor } = params;
|
||||
@@ -61,4 +62,4 @@ const IssuesLayout = observer((props: Props) => {
|
||||
);
|
||||
});
|
||||
|
||||
export default IssuesLayout;
|
||||
export default ViewsLayout;
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
// components
|
||||
import { PoweredBy } from "@/components/common";
|
||||
import { PoweredBy } from "@/components/common/powered-by";
|
||||
// hooks
|
||||
import { usePublish } from "@/hooks/store";
|
||||
import { usePublish } from "@/hooks/store/publish";
|
||||
// plane-web
|
||||
import { ViewLayoutsRoot } from "@/plane-web/components/issue-layouts/root";
|
||||
|
||||
@@ -15,7 +15,7 @@ type Props = {
|
||||
};
|
||||
};
|
||||
|
||||
const IssuesPage = observer((props: Props) => {
|
||||
const ViewsPage = observer((props: Props) => {
|
||||
const { params } = props;
|
||||
const { anchor } = params;
|
||||
// params
|
||||
@@ -34,4 +34,4 @@ const IssuesPage = observer((props: Props) => {
|
||||
);
|
||||
});
|
||||
|
||||
export default IssuesPage;
|
||||
export default ViewsPage;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { PageNotFound } from "@/components/ui/not-found";
|
||||
import { PublishStore } from "@/store/publish/publish.store";
|
||||
import type { PublishStore } from "@/store/publish/publish.store";
|
||||
|
||||
type Props = {
|
||||
peekId: string | undefined;
|
||||
publishSettings: PublishStore;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const ViewLayoutsRoot = (props: Props) => <PageNotFound />;
|
||||
export const ViewLayoutsRoot = (_props: Props) => <PageNotFound />;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PublishStore } from "@/store/publish/publish.store";
|
||||
import type { PublishStore } from "@/store/publish/publish.store";
|
||||
|
||||
type Props = {
|
||||
publishSettings: PublishStore;
|
||||
|
||||
@@ -11,14 +11,6 @@ import { SitesAuthService } from "@plane/services";
|
||||
import { IEmailCheckData } from "@plane/types";
|
||||
import { OAuthOptions } from "@plane/ui";
|
||||
// components
|
||||
import {
|
||||
AuthHeader,
|
||||
AuthBanner,
|
||||
AuthEmailForm,
|
||||
AuthUniqueCodeForm,
|
||||
AuthPasswordForm,
|
||||
TermsAndConditions,
|
||||
} from "@/components/account";
|
||||
// helpers
|
||||
import {
|
||||
EAuthenticationErrorCodes,
|
||||
@@ -27,7 +19,7 @@ import {
|
||||
authErrorHandler,
|
||||
} from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
// types
|
||||
import { EAuthModes, EAuthSteps } from "@/types/auth";
|
||||
// assets
|
||||
@@ -35,6 +27,13 @@ import GithubLightLogo from "/public/logos/github-black.png";
|
||||
import GithubDarkLogo from "/public/logos/github-dark.svg";
|
||||
import GitlabLogo from "/public/logos/gitlab-logo.svg";
|
||||
import GoogleLogo from "/public/logos/google-logo.svg";
|
||||
// local imports
|
||||
import { TermsAndConditions } from "../terms-and-conditions";
|
||||
import { AuthBanner } from "./auth-banner";
|
||||
import { AuthHeader } from "./auth-header";
|
||||
import { AuthEmailForm } from "./email";
|
||||
import { AuthPasswordForm } from "./password";
|
||||
import { AuthUniqueCodeForm } from "./unique-code";
|
||||
|
||||
const authService = new SitesAuthService();
|
||||
|
||||
|
||||
@@ -1,8 +1 @@
|
||||
export * from "./auth-root";
|
||||
|
||||
export * from "./auth-header";
|
||||
export * from "./auth-banner";
|
||||
|
||||
export * from "./email";
|
||||
export * from "./password";
|
||||
export * from "./unique-code";
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./auth-forms";
|
||||
export * from "./terms-and-conditions";
|
||||
export * from "./user-logged-in";
|
||||
@@ -4,10 +4,10 @@ import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { PlaneLockup } from "@plane/ui";
|
||||
// components
|
||||
import { PoweredBy } from "@/components/common";
|
||||
import { UserAvatar } from "@/components/issues";
|
||||
import { PoweredBy } from "@/components/common/powered-by";
|
||||
import { UserAvatar } from "@/components/issues/navbar/user-avatar";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
import { useUser } from "@/hooks/store/use-user";
|
||||
// assets
|
||||
import UserLoggedInImage from "@/public/user-logged-in.svg";
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./project-logo";
|
||||
export * from "./logo-spinner";
|
||||
export * from "./powered-by";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./mentions";
|
||||
@@ -2,7 +2,8 @@ import { observer } from "mobx-react";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useMember, useUser } from "@/hooks/store";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useUser } from "@/hooks/store/use-user";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from "./embeds";
|
||||
export * from "./lite-text-editor";
|
||||
export * from "./rich-text-editor";
|
||||
export * from "./toolbar";
|
||||
@@ -3,12 +3,13 @@ import React from "react";
|
||||
import { type EditorRefApi, type ILiteTextEditorProps, LiteTextEditorWithRef, type TFileHandler } from "@plane/editor";
|
||||
import type { MakeOptional } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { EditorMentionsRoot, IssueCommentToolbar } from "@/components/editor";
|
||||
// helpers
|
||||
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
import { isCommentEmpty } from "@/helpers/string.helper";
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
// local imports
|
||||
import { EditorMentionsRoot } from "./embeds/mentions";
|
||||
import { IssueCommentToolbar } from "./toolbar";
|
||||
|
||||
type LiteTextEditorWrapperProps = MakeOptional<
|
||||
Omit<ILiteTextEditorProps, "fileHandler" | "mentionHandler">,
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import React, { forwardRef } from "react";
|
||||
// plane imports
|
||||
import { useEditorFlagging } from "ce/hooks/use-editor-flagging";
|
||||
import { EditorRefApi, IRichTextEditorProps, RichTextEditorWithRef, TFileHandler } from "@plane/editor";
|
||||
import { MakeOptional } from "@plane/types";
|
||||
// components
|
||||
import { EditorMentionsRoot } from "@/components/editor";
|
||||
import { type EditorRefApi, type IRichTextEditorProps, RichTextEditorWithRef, type TFileHandler } from "@plane/editor";
|
||||
import type { MakeOptional } from "@plane/types";
|
||||
// helpers
|
||||
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
// store hooks
|
||||
import { useMember } from "@/hooks/store";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
// plane web imports
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
// local imports
|
||||
import { EditorMentionsRoot } from "./embeds/mentions";
|
||||
|
||||
type RichTextEditorWrapperProps = MakeOptional<
|
||||
Omit<IRichTextEditorProps, "editable" | "fileHandler" | "mentionHandler">,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
// plane imports
|
||||
import { TOOLBAR_ITEMS, ToolbarMenuItem, EditorRefApi } from "@plane/editor";
|
||||
import { TOOLBAR_ITEMS, type ToolbarMenuItem, type EditorRefApi } from "@plane/editor";
|
||||
import { Button, Tooltip } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./instance-failure-view";
|
||||
@@ -5,7 +5,7 @@ import cloneDeep from "lodash/cloneDeep";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
// hooks
|
||||
import { useIssueFilter } from "@/hooks/store";
|
||||
import { useIssueFilter } from "@/hooks/store/use-issue-filter";
|
||||
// store
|
||||
import type { TIssueLayout, TIssueQueryFilters } from "@/types/issue";
|
||||
// components
|
||||
|
||||
@@ -6,7 +6,7 @@ import { X } from "lucide-react";
|
||||
import { EIconSize } from "@plane/constants";
|
||||
import { StateGroupIcon } from "@plane/ui";
|
||||
// hooks
|
||||
import { useStates } from "@/hooks/store";
|
||||
import { useStates } from "@/hooks/store/use-state";
|
||||
|
||||
type Props = {
|
||||
handleRemove: (val: string) => void;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./dropdown";
|
||||
export * from "./filter-header";
|
||||
export * from "./filter-option";
|
||||
@@ -1,11 +1 @@
|
||||
// filters
|
||||
export * from "./root";
|
||||
export * from "./selection";
|
||||
|
||||
// properties
|
||||
export * from "./state";
|
||||
export * from "./priority";
|
||||
export * from "./labels";
|
||||
|
||||
// helpers
|
||||
export * from "./helpers";
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
// plane imports
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { FilterHeader, FilterOption } from "@/components/issues/filters/helpers";
|
||||
// types
|
||||
import { IIssueLabel } from "@/types/issue";
|
||||
import type { IIssueLabel } from "@/types/issue";
|
||||
// local imports
|
||||
import { FilterHeader } from "./helpers/filter-header";
|
||||
import { FilterOption } from "./helpers/filter-option";
|
||||
|
||||
const LabelIcons = ({ color }: { color: string }) => (
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { ISSUE_PRIORITY_FILTERS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { PriorityIcon } from "@plane/ui";
|
||||
// components
|
||||
import { FilterHeader, FilterOption } from "./helpers";
|
||||
// constants
|
||||
// local imports
|
||||
import { FilterHeader } from "./helpers/filter-header";
|
||||
import { FilterOption } from "./helpers/filter-option";
|
||||
|
||||
type Props = {
|
||||
appliedFilters: string[] | null;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { FilterSelection } from "@/components/issues/filters/selection";
|
||||
// helpers
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
// hooks
|
||||
import { useIssueFilter } from "@/hooks/store";
|
||||
import { useIssueFilter } from "@/hooks/store/use-issue-filter";
|
||||
// types
|
||||
import { TIssueQueryFilters } from "@/types/issue";
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@ import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Search, X } from "lucide-react";
|
||||
// types
|
||||
import { IIssueFilterOptions, TIssueFilterKeys } from "@/types/issue";
|
||||
// components
|
||||
import { FilterPriority, FilterState } from ".";
|
||||
import type { IIssueFilterOptions, TIssueFilterKeys } from "@/types/issue";
|
||||
// local imports
|
||||
import { FilterPriority } from "./priority";
|
||||
import { FilterState } from "./state";
|
||||
|
||||
type Props = {
|
||||
filters: IIssueFilterOptions;
|
||||
|
||||
@@ -5,10 +5,11 @@ import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { EIconSize } from "@plane/constants";
|
||||
import { Loader, StateGroupIcon } from "@plane/ui";
|
||||
// components
|
||||
import { FilterHeader, FilterOption } from "@/components/issues/filters/helpers";
|
||||
// hooks
|
||||
import { useStates } from "@/hooks/store";
|
||||
import { useStates } from "@/hooks/store/use-state";
|
||||
// local imports
|
||||
import { FilterHeader } from "./helpers/filter-header";
|
||||
import { FilterOption } from "./helpers/filter-option";
|
||||
|
||||
type Props = {
|
||||
appliedFilters: string[] | null;
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./issue-layouts";
|
||||
export * from "./navbar";
|
||||
@@ -1,4 +1 @@
|
||||
export * from "./kanban/base-kanban-root";
|
||||
export * from "./list/base-list-root";
|
||||
export * from "./properties";
|
||||
export * from "./root";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { TLoader } from "@plane/types";
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
// plane imports
|
||||
import type { TLoader } from "@plane/types";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
|
||||
interface Props {
|
||||
children: string | React.ReactNode | React.ReactNode[];
|
||||
|
||||
@@ -8,7 +8,7 @@ import { IIssueDisplayProperties } from "@plane/types";
|
||||
// components
|
||||
import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC";
|
||||
// hooks
|
||||
import { useIssue } from "@/hooks/store";
|
||||
import { useIssue } from "@/hooks/store/use-issue";
|
||||
|
||||
import { KanBan } from "./default";
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ import { useParams } from "next/navigation";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { IssueEmojiReactions, IssueVotes } from "@/components/issues/reactions";
|
||||
import { IssueEmojiReactions } from "@/components/issues/reactions/issue-emoji-reactions";
|
||||
import { IssueVotes } from "@/components/issues/reactions/issue-vote-reactions";
|
||||
// hooks
|
||||
import { usePublish } from "@/hooks/store";
|
||||
import { usePublish } from "@/hooks/store/publish";
|
||||
|
||||
type Props = {
|
||||
issueId: string;
|
||||
|
||||
@@ -15,7 +15,8 @@ import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with
|
||||
// helpers
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
// hooks
|
||||
import { useIssueDetails, usePublish } from "@/hooks/store";
|
||||
import { usePublish } from "@/hooks/store/publish";
|
||||
import { useIssueDetails } from "@/hooks/store/use-issue-details";
|
||||
//
|
||||
import { IIssue } from "@/types/issue";
|
||||
import { IssueProperties } from "../properties/all-properties";
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
TLoader,
|
||||
} from "@plane/types";
|
||||
// hooks
|
||||
import { useMember, useModule, useStates, useLabel, useCycle } from "@/hooks/store";
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
import { useStates } from "@/hooks/store/use-state";
|
||||
//
|
||||
import { getGroupByColumns } from "../utils";
|
||||
// components
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./block";
|
||||
export * from "./blocks-list";
|
||||
@@ -3,7 +3,7 @@
|
||||
import { MutableRefObject, forwardRef, useCallback, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
//types
|
||||
import {
|
||||
import type {
|
||||
TGroupedIssues,
|
||||
IIssueDisplayProperties,
|
||||
TSubGroupedIssues,
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
|
||||
//
|
||||
import { KanbanIssueBlocksList } from ".";
|
||||
// local imports
|
||||
import { KanbanIssueBlocksList } from "./blocks-list";
|
||||
|
||||
interface IKanbanGroup {
|
||||
groupId: string;
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
TLoader,
|
||||
} from "@plane/types";
|
||||
// hooks
|
||||
import { useMember, useModule, useStates, useLabel, useCycle } from "@/hooks/store";
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
import { useStates } from "@/hooks/store/use-state";
|
||||
//
|
||||
import { getGroupByColumns } from "../utils";
|
||||
import { KanBan } from "./default";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IIssueDisplayProperties, TGroupedIssues } from "@plane/types";
|
||||
// components
|
||||
import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC";
|
||||
// hooks
|
||||
import { useIssue } from "@/hooks/store";
|
||||
import { useIssue } from "@/hooks/store/use-issue";
|
||||
import { List } from "./default";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -13,7 +13,8 @@ import { cn } from "@plane/utils";
|
||||
// helpers
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
// hooks
|
||||
import { useIssueDetails, usePublish } from "@/hooks/store";
|
||||
import { usePublish } from "@/hooks/store/publish";
|
||||
import { useIssueDetails } from "@/hooks/store/use-issue-details";
|
||||
//
|
||||
import { IssueProperties } from "../properties/all-properties";
|
||||
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
TLoader,
|
||||
} from "@plane/types";
|
||||
// hooks
|
||||
import { useMember, useModule, useStates, useLabel, useCycle } from "@/hooks/store";
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
import { useStates } from "@/hooks/store/use-state";
|
||||
//
|
||||
import { getGroupByColumns } from "../utils";
|
||||
import { ListGroup } from "./list-group";
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./block";
|
||||
export * from "./blocks-list";
|
||||
@@ -2,27 +2,23 @@
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Layers, Link, Paperclip } from "lucide-react";
|
||||
// plane types
|
||||
import { IIssueDisplayProperties } from "@plane/types";
|
||||
// plane ui
|
||||
// plane imports
|
||||
import type { IIssueDisplayProperties } from "@plane/types";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import {
|
||||
IssueBlockDate,
|
||||
IssueBlockLabels,
|
||||
IssueBlockPriority,
|
||||
IssueBlockState,
|
||||
IssueBlockMembers,
|
||||
IssueBlockModules,
|
||||
IssueBlockCycle,
|
||||
} from "@/components/issues";
|
||||
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with-display-properties-HOC";
|
||||
// helpers
|
||||
import { getDate } from "@/helpers/date-time.helper";
|
||||
//// hooks
|
||||
import { IIssue } from "@/types/issue";
|
||||
import { IssueBlockCycle } from "./cycle";
|
||||
import { IssueBlockDate } from "./due-date";
|
||||
import { IssueBlockLabels } from "./labels";
|
||||
import { IssueBlockMembers } from "./member";
|
||||
import { IssueBlockModules } from "./modules";
|
||||
import { IssueBlockPriority } from "./priority";
|
||||
import { IssueBlockState } from "./state";
|
||||
|
||||
export interface IIssueProperties {
|
||||
issue: IIssue;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user