mirror of
https://github.com/flameshikari/outline-ru.git
synced 2026-06-13 04:05:10 +03:00
1.7.0 (#32)
* bump version to 1.7.0 * update HMR mode; make a single dev container for every service; etc * update translations
This commit is contained in:
@@ -1,11 +1,6 @@
|
|||||||
APP_PATH=/opt/outline
|
COMPOSE_PROFILES=dev
|
||||||
SRC_PATH=./outline
|
|
||||||
|
|
||||||
ADDRESS=localhost
|
# PORT=10240
|
||||||
PORT_OUTLINE=10240
|
# PORT_OIDC=10241
|
||||||
PORT_OIDC=10241
|
# PORT_VITE=10242
|
||||||
PORT_REDIS=10242
|
# SECRET=deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef # openssl rand -hex 32
|
||||||
PORT_POSTGRES=10243
|
|
||||||
|
|
||||||
COMMON=outline
|
|
||||||
SECRET=deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef
|
|
||||||
|
|||||||
+13
-15
@@ -9,8 +9,7 @@ on:
|
|||||||
- .github/workflows/**
|
- .github/workflows/**
|
||||||
- outline/**
|
- outline/**
|
||||||
- translation/ru.json
|
- translation/ru.json
|
||||||
- patches/**
|
- Dockerfile
|
||||||
- Dockerfile.prod
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -42,16 +41,16 @@ jobs:
|
|||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup Docker Buildx
|
- name: Setup Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Login to GitHub CR
|
- name: Login to GitHub CR
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -65,7 +64,7 @@ jobs:
|
|||||||
echo "version=$version" | tee -a $GITHUB_OUTPUT
|
echo "version=$version" | tee -a $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Set Metadata
|
- name: Set Metadata
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
id: metadata
|
id: metadata
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
@@ -73,13 +72,12 @@ jobs:
|
|||||||
ghcr.io/${{ github.repository }}
|
ghcr.io/${{ github.repository }}
|
||||||
|
|
||||||
- name: Build Image
|
- name: Build Image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
id: build
|
id: build
|
||||||
with:
|
with:
|
||||||
cache-from: type=gha,scope=build-${{ matrix.arch }}
|
cache-from: type=gha,scope=build-${{ matrix.arch }}
|
||||||
cache-to: type=gha,mode=max,scope=build-${{ matrix.arch }}
|
cache-to: type=gha,mode=max,scope=build-${{ matrix.arch }}
|
||||||
platforms: linux/${{ matrix.arch }}
|
platforms: linux/${{ matrix.arch }}
|
||||||
file: Dockerfile.prod
|
|
||||||
build-args: |
|
build-args: |
|
||||||
APP_PATH=/opt/outline
|
APP_PATH=/opt/outline
|
||||||
SRC_PATH=./outline
|
SRC_PATH=./outline
|
||||||
@@ -93,7 +91,7 @@ jobs:
|
|||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: Upload Digests
|
- name: Upload Digests
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: digests-linux-${{ matrix.arch }}
|
name: digests-linux-${{ matrix.arch }}
|
||||||
path: ${{ runner.temp }}/digests/*
|
path: ${{ runner.temp }}/digests/*
|
||||||
@@ -111,30 +109,30 @@ jobs:
|
|||||||
version: ${{ needs.build.outputs.version }}
|
version: ${{ needs.build.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Download Digests
|
- name: Download Digests
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/digests
|
path: ${{ runner.temp }}/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Login to GitHub CR
|
- name: Login to GitHub CR
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Setup Docker Buildx
|
- name: Setup Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Set Metadata
|
- name: Set Metadata
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ github.repository }}
|
${{ github.repository }}
|
||||||
@@ -153,7 +151,7 @@ jobs:
|
|||||||
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
if: ${{ github.ref == 'refs/heads/master' }}
|
if: ${{ github.ref == 'refs/heads/master' }}
|
||||||
with:
|
with:
|
||||||
name: ${{ env.version }}
|
name: ${{ env.version }}
|
||||||
|
|||||||
+34
-13
@@ -1,7 +1,10 @@
|
|||||||
FROM node:22.21.0
|
ARG APP_PATH=/opt/outline
|
||||||
|
ARG SRC_PATH=./outline
|
||||||
|
|
||||||
|
FROM node:24.15.0 AS build
|
||||||
|
ARG CDN_URL
|
||||||
ARG APP_PATH
|
ARG APP_PATH
|
||||||
ARG SRC_PATH
|
ARG SRC_PATH
|
||||||
ARG CDN_URL
|
|
||||||
WORKDIR $APP_PATH
|
WORKDIR $APP_PATH
|
||||||
COPY ${SRC_PATH}/package.json ${SRC_PATH}/yarn.lock ${SRC_PATH}/.yarnrc.yml ./
|
COPY ${SRC_PATH}/package.json ${SRC_PATH}/yarn.lock ${SRC_PATH}/.yarnrc.yml ./
|
||||||
COPY ${SRC_PATH}/patches ./patches
|
COPY ${SRC_PATH}/patches ./patches
|
||||||
@@ -10,16 +13,34 @@ RUN corepack enable && \
|
|||||||
yarn install --immutable --network-timeout 1000000 && \
|
yarn install --immutable --network-timeout 1000000 && \
|
||||||
yarn cache clean
|
yarn cache clean
|
||||||
COPY ${SRC_PATH} .
|
COPY ${SRC_PATH} .
|
||||||
COPY ./patches/* .
|
COPY ./patches/lang.patch .
|
||||||
RUN for patch in $(ls *.patch); do patch -p1 < $patch; done
|
RUN patch -p1 < lang.patch
|
||||||
RUN cat << EOF > /entrypoint.sh
|
COPY ./translation/ru.json ./shared/i18n/locales/ru_RU/translation.json
|
||||||
npx yarn concurrently -n "dev,i18n" \
|
RUN yarn build && \
|
||||||
"yarn dev:watch" \
|
yarn workspaces focus --production
|
||||||
"yarn nodemon \
|
|
||||||
--watch './shared/i18n/locales/ru_RU' \
|
FROM node:24.15.0-slim AS release
|
||||||
--exec 'yarn build:i18n'"
|
RUN apt-get update && \
|
||||||
EOF
|
apt-get install -y curl && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
ENV DATA_PATH=/var/lib/outline/data
|
ENV DATA_PATH=/var/lib/outline/data
|
||||||
|
ENV USER=nodejs
|
||||||
|
RUN addgroup --gid 1001 ${USER} && \
|
||||||
|
adduser --uid 1001 --ingroup ${USER} ${USER} && \
|
||||||
|
mkdir -p ${DATA_PATH} && \
|
||||||
|
chown -R ${USER}:${USER} ${DATA_PATH}/..
|
||||||
|
ARG APP_PATH
|
||||||
|
WORKDIR $APP_PATH
|
||||||
|
COPY --chown=${USER}:${USER} --from=build $APP_PATH/node_modules ./node_modules
|
||||||
|
COPY --chown=${USER}:${USER} --from=build $APP_PATH/build ./build
|
||||||
|
COPY --chown=${USER}:${USER} --from=build $APP_PATH/server ./server
|
||||||
|
COPY --chown=${USER}:${USER} --from=build $APP_PATH/public ./public
|
||||||
|
COPY --chown=${USER}:${USER} --from=build $APP_PATH/.sequelizerc .
|
||||||
|
COPY --chown=${USER}:${USER} --from=build $APP_PATH/package.json .
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
USER ${USER}
|
||||||
|
EXPOSE ${PORT}
|
||||||
VOLUME ${DATA_PATH}
|
VOLUME ${DATA_PATH}
|
||||||
STOPSIGNAL SIGKILL
|
HEALTHCHECK --interval=1m CMD curl -fs localhost:${PORT}/_health | grep -q OK || exit 1
|
||||||
ENTRYPOINT ["bash", "/entrypoint.sh"]
|
CMD ["node", "build/server/index.js"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
ARG APP_PATH=/opt/outline
|
||||||
|
ARG SRC_PATH=./outline
|
||||||
|
|
||||||
|
FROM node:24.15.0
|
||||||
|
ARG APP_PATH
|
||||||
|
ARG SRC_PATH
|
||||||
|
ARG CDN_URL
|
||||||
|
WORKDIR $APP_PATH
|
||||||
|
COPY ${SRC_PATH}/package.json ${SRC_PATH}/yarn.lock ${SRC_PATH}/.yarnrc.yml ./
|
||||||
|
COPY ${SRC_PATH}/patches ./patches
|
||||||
|
ENV NODE_OPTIONS='--max-old-space-size=24000'
|
||||||
|
RUN corepack enable && \
|
||||||
|
yarn install --immutable --network-timeout 1000000 && \
|
||||||
|
yarn cache clean
|
||||||
|
COPY ${SRC_PATH} .
|
||||||
|
COPY ./patches/* .
|
||||||
|
RUN for patch in $(ls *.patch); do patch -p1 < $patch; done
|
||||||
|
RUN cat << EOF > /entrypoint.sh
|
||||||
|
yarn dev:watch
|
||||||
|
EOF
|
||||||
|
ENV DATA_PATH=/var/lib/outline/data
|
||||||
|
VOLUME ${DATA_PATH}
|
||||||
|
STOPSIGNAL SIGKILL
|
||||||
|
ENTRYPOINT ["bash", "/entrypoint.sh"]
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
FROM node:22.21.0 AS build
|
|
||||||
ARG APP_PATH
|
|
||||||
ARG SRC_PATH
|
|
||||||
ARG CDN_URL
|
|
||||||
WORKDIR $APP_PATH
|
|
||||||
COPY ${SRC_PATH}/package.json ${SRC_PATH}/yarn.lock ${SRC_PATH}/.yarnrc.yml ./
|
|
||||||
COPY ${SRC_PATH}/patches ./patches
|
|
||||||
ENV NODE_OPTIONS='--max-old-space-size=24000'
|
|
||||||
RUN corepack enable && \
|
|
||||||
yarn install --immutable --network-timeout 1000000 && \
|
|
||||||
yarn cache clean
|
|
||||||
COPY ${SRC_PATH} .
|
|
||||||
COPY ./patches/lang.patch .
|
|
||||||
RUN patch -p1 < lang.patch
|
|
||||||
COPY ./translation/ru.json ./shared/i18n/locales/ru_RU/translation.json
|
|
||||||
RUN yarn build && \
|
|
||||||
yarn workspaces focus --production
|
|
||||||
|
|
||||||
FROM node:22.21.0-slim AS release
|
|
||||||
RUN apt-get update && \
|
|
||||||
apt-get install -y curl && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
ENV DATA_PATH=/var/lib/outline/data
|
|
||||||
ENV USER=nodejs
|
|
||||||
RUN addgroup --gid 1001 ${USER} && \
|
|
||||||
adduser --uid 1001 --ingroup ${USER} ${USER} && \
|
|
||||||
mkdir -p ${DATA_PATH} && \
|
|
||||||
chown -R ${USER}:${USER} ${DATA_PATH}/..
|
|
||||||
ARG APP_PATH
|
|
||||||
WORKDIR $APP_PATH
|
|
||||||
COPY --chown=${USER}:${USER} --from=build $APP_PATH/node_modules ./node_modules
|
|
||||||
COPY --chown=${USER}:${USER} --from=build $APP_PATH/build ./build
|
|
||||||
COPY --chown=${USER}:${USER} --from=build $APP_PATH/server ./server
|
|
||||||
COPY --chown=${USER}:${USER} --from=build $APP_PATH/public ./public
|
|
||||||
COPY --chown=${USER}:${USER} --from=build $APP_PATH/.sequelizerc .
|
|
||||||
COPY --chown=${USER}:${USER} --from=build $APP_PATH/package.json .
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV PORT=3000
|
|
||||||
USER ${USER}
|
|
||||||
EXPOSE ${PORT}
|
|
||||||
VOLUME ${DATA_PATH}
|
|
||||||
HEALTHCHECK --interval=1m CMD curl -fs localhost:${PORT}/_health | grep -q OK || exit 1
|
|
||||||
CMD ["node", "build/server/index.js"]
|
|
||||||
@@ -20,8 +20,8 @@
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
outline:
|
outline:
|
||||||
image: flameshikari/outline-ru:1.6.1
|
image: flameshikari/outline-ru:1.7.0
|
||||||
# image: ghcr.io/flameshikari/outline-ru:1.6.1
|
# image: ghcr.io/flameshikari/outline-ru:1.7.0
|
||||||
env_file: ./docker.env
|
env_file: ./docker.env
|
||||||
expose:
|
expose:
|
||||||
- 3000
|
- 3000
|
||||||
@@ -93,7 +93,7 @@ services:
|
|||||||
|
|
||||||
2. Пулл изменений в подмодуле и переключение на последний доступный тег:
|
2. Пулл изменений в подмодуле и переключение на последний доступный тег:
|
||||||
```sh
|
```sh
|
||||||
git submodule foreach 'git pull --rebase --tags && git checkout v1.6.1'
|
git submodule foreach 'git pull --rebase --tags && git checkout v1.7.0'
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Запуск контейнеров:
|
3. Запуск контейнеров:
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
volumes:
|
|
||||||
outline:
|
|
||||||
name: outline
|
|
||||||
outline-postgres:
|
|
||||||
name: outline-postgres
|
|
||||||
|
|
||||||
networks:
|
|
||||||
default:
|
|
||||||
name: outline
|
|
||||||
|
|
||||||
services:
|
|
||||||
outline:
|
|
||||||
container_name: outline
|
|
||||||
image: flameshikari/outline-ru:nightly
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.prod
|
|
||||||
args:
|
|
||||||
- APP_PATH=${APP_PATH}
|
|
||||||
- SRC_PATH=${SRC_PATH}
|
|
||||||
network_mode: host
|
|
||||||
pull_policy: always
|
|
||||||
volumes:
|
|
||||||
- outline:/var/lib/outline/data
|
|
||||||
depends_on:
|
|
||||||
- outline-postgres
|
|
||||||
- outline-redis
|
|
||||||
- outline-oidc
|
|
||||||
environment:
|
|
||||||
FILE_STORAGE: local
|
|
||||||
FORCE_HTTPS: false
|
|
||||||
PORT: ${PORT_OUTLINE}
|
|
||||||
URL: http://${ADDRESS}:${PORT_OUTLINE}
|
|
||||||
SECRET_KEY: ${SECRET}
|
|
||||||
UTILS_SECRET: ${SECRET}
|
|
||||||
REDIS_URL: redis://${ADDRESS}:${PORT_REDIS}
|
|
||||||
DATABASE_URL: postgres://${COMMON}:${COMMON}@${ADDRESS}:${PORT_POSTGRES}/${COMMON}
|
|
||||||
PGSSLMODE: disable
|
|
||||||
OIDC_ISSUER_URL: http://${ADDRESS}:${PORT_OIDC}
|
|
||||||
OIDC_CLIENT_ID: ${COMMON}
|
|
||||||
OIDC_CLIENT_SECRET: ${COMMON}
|
|
||||||
|
|
||||||
outline-oidc:
|
|
||||||
container_name: outline-oidc
|
|
||||||
image: ghcr.io/soluto/oidc-server-mock:0.11.0
|
|
||||||
ports:
|
|
||||||
- ${PORT_OIDC}:80
|
|
||||||
healthcheck:
|
|
||||||
test: curl -fs ${ADDRESS}/health || exit 1
|
|
||||||
start_period: 2s
|
|
||||||
interval: 1s
|
|
||||||
timeout: 100ms
|
|
||||||
retries: 10
|
|
||||||
environment:
|
|
||||||
ASPNETCORE_URLS: http://+:80
|
|
||||||
ASPNETCORE_ENVIRONMENT: Development
|
|
||||||
CLIENTS_CONFIGURATION_INLINE: |
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"ClientId": "${COMMON}",
|
|
||||||
"ClientSecrets": ["${COMMON}"],
|
|
||||||
"RedirectUris": ["http://${ADDRESS}:${PORT_OUTLINE}/auth/oidc.callback"],
|
|
||||||
"AllowedGrantTypes": ["authorization_code"],
|
|
||||||
"AllowedScopes": ["openid", "profile", "email"],
|
|
||||||
"RequirePkce": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
USERS_CONFIGURATION_INLINE: |
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"SubjectId": "1",
|
|
||||||
"Username": "${COMMON}",
|
|
||||||
"Password": "${COMMON}",
|
|
||||||
"Claims": [
|
|
||||||
{
|
|
||||||
"Type": "email",
|
|
||||||
"Value": "mail@example.com",
|
|
||||||
"ValueType": "string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Type": "name",
|
|
||||||
"Value": "Outline",
|
|
||||||
"ValueType": "string"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
SERVER_OPTIONS_INLINE: |
|
|
||||||
{
|
|
||||||
"AccessTokenJwtType": "JWT",
|
|
||||||
"Discovery": {
|
|
||||||
"ShowKeySet": true
|
|
||||||
},
|
|
||||||
"Authentication": {
|
|
||||||
"CookieSameSiteMode": "Lax",
|
|
||||||
"CheckSessionCookieSameSiteMode": "Lax"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LOGIN_OPTIONS_INLINE: |
|
|
||||||
{
|
|
||||||
"AllowRememberLogin": false
|
|
||||||
}
|
|
||||||
LOGOUT_OPTIONS_INLINE: |
|
|
||||||
{
|
|
||||||
"AutomaticRedirectAfterSignOut": true
|
|
||||||
}
|
|
||||||
ASPNET_SERVICES_OPTIONS_INLINE: |
|
|
||||||
{
|
|
||||||
"ForwardedHeadersOptions": {
|
|
||||||
"ForwardedHeaders" : "All"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
outline-redis:
|
|
||||||
container_name: outline-redis
|
|
||||||
image: redis:7
|
|
||||||
ports:
|
|
||||||
- ${PORT_REDIS}:6379
|
|
||||||
healthcheck:
|
|
||||||
test: redis-cli ping
|
|
||||||
interval: 10s
|
|
||||||
timeout: 30s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
outline-postgres:
|
|
||||||
container_name: outline-postgres
|
|
||||||
image: postgres:17
|
|
||||||
ports:
|
|
||||||
- ${PORT_POSTGRES}:5432
|
|
||||||
volumes:
|
|
||||||
- outline-postgres:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: pg_isready
|
|
||||||
interval: 30s
|
|
||||||
timeout: 20s
|
|
||||||
retries: 3
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: ${COMMON}
|
|
||||||
POSTGRES_PASSWORD: ${COMMON}
|
|
||||||
POSTGRES_DB: ${COMMON}
|
|
||||||
+58
-25
@@ -8,33 +8,66 @@ networks:
|
|||||||
default:
|
default:
|
||||||
name: outline
|
name: outline
|
||||||
|
|
||||||
|
x-outline-base: &outline-base
|
||||||
|
container_name: outline
|
||||||
|
depends_on:
|
||||||
|
- outline-services
|
||||||
|
environment:
|
||||||
|
FILE_STORAGE: local
|
||||||
|
FORCE_HTTPS: false
|
||||||
|
PORT: ${PORT:-10240}
|
||||||
|
PORT_VITE: ${PORT_VITE:-10242}
|
||||||
|
URL: http://127.0.0.1:${PORT:-10240}
|
||||||
|
SECRET_KEY: ${SECRET:-deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef}
|
||||||
|
UTILS_SECRET: ${SECRET:-deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef}
|
||||||
|
REDIS_URL: redis://outline-services
|
||||||
|
DATABASE_URL: postgres://outline:outline@outline-services/outline
|
||||||
|
PGSSLMODE: disable
|
||||||
|
OIDC_ISSUER_URL: http://outline-services:8080
|
||||||
|
OIDC_CLIENT_ID: outline
|
||||||
|
OIDC_CLIENT_SECRET: outline
|
||||||
|
OIDC_DISPLAY_NAME: OIDC
|
||||||
|
|
||||||
services:
|
services:
|
||||||
outline:
|
outline:
|
||||||
extends:
|
<<: *outline-base
|
||||||
file: docker-compose.prod.yml
|
profiles: [prod]
|
||||||
service: outline
|
build: .
|
||||||
image: !reset
|
image: flameshikari/outline-ru:nightly
|
||||||
pull_policy: !reset
|
ports:
|
||||||
build:
|
- ${PORT:-10240}:${PORT:-10240}
|
||||||
dockerfile: !reset
|
pull_policy: always
|
||||||
depends_on:
|
|
||||||
- outline-postgres
|
|
||||||
- outline-redis
|
|
||||||
- outline-oidc
|
|
||||||
volumes:
|
volumes:
|
||||||
|
- outline:/var/lib/outline/data
|
||||||
|
|
||||||
|
outline-dev:
|
||||||
|
<<: *outline-base
|
||||||
|
build:
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
profiles: [dev]
|
||||||
|
ports:
|
||||||
|
- ${PORT:-10240}:${PORT:-10240}
|
||||||
|
- ${PORT_VITE:-10242}:${PORT_VITE:-10242}
|
||||||
|
volumes:
|
||||||
|
- outline:/var/lib/outline/data
|
||||||
- ./translation/ru.json:/opt/outline/shared/i18n/locales/ru_RU/translation.json
|
- ./translation/ru.json:/opt/outline/shared/i18n/locales/ru_RU/translation.json
|
||||||
|
|
||||||
outline-oidc:
|
outline-services:
|
||||||
extends:
|
container_name: outline-services
|
||||||
file: docker-compose.prod.yml
|
image: outline-services
|
||||||
service: outline-oidc
|
build: services
|
||||||
|
environment:
|
||||||
outline-redis:
|
ISSUER: http://outline-services:8080
|
||||||
extends:
|
PUBLIC_URL: http://127.0.0.1:${PORT_OIDC:-10241}
|
||||||
file: docker-compose.prod.yml
|
volumes:
|
||||||
service: outline-redis
|
- outline-postgres:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
outline-postgres:
|
- ${PORT_OIDC:-10241}:8080
|
||||||
extends:
|
healthcheck:
|
||||||
file: docker-compose.prod.yml
|
test: |
|
||||||
service: outline-postgres
|
pg_isready -U outline && \
|
||||||
|
redis-cli ping && \
|
||||||
|
wget -qO- http://127.0.0.1:8080/health
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|||||||
+1
-1
Submodule outline updated: 05eac5bc3b...568b4ac074
@@ -0,0 +1,122 @@
|
|||||||
|
diff --git a/app/utils/i18n.ts b/app/utils/i18n.ts
|
||||||
|
index d20fe802a..de4987a99 100644
|
||||||
|
--- a/app/utils/i18n.ts
|
||||||
|
+++ b/app/utils/i18n.ts
|
||||||
|
@@ -51,5 +51,16 @@ export function initI18n(defaultLanguage = "en_US") {
|
||||||
|
Logger.error("Failed to initialize i18n", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
+ // HMR: when a translation JSON changes on disk, the Vite dev server emits
|
||||||
|
+ // an "i18n:update" event (see vite.config.ts). Reload the resources for
|
||||||
|
+ // that language and ask react-i18next to re-render the tree.
|
||||||
|
+ if (typeof import.meta.hot !== "undefined") {
|
||||||
|
+ import.meta.hot.on("i18n:update", async ({ lng: changed }) => {
|
||||||
|
+ const target = unicodeCLDRtoBCP47(changed);
|
||||||
|
+ await i18n.reloadResources(target);
|
||||||
|
+ i18n.emit("languageChanged", i18n.language);
|
||||||
|
+ });
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
return i18n;
|
||||||
|
}
|
||||||
|
diff --git a/server/middlewares/csp.ts b/server/middlewares/csp.ts
|
||||||
|
index 477793da6..93bc7c6e9 100644
|
||||||
|
--- a/server/middlewares/csp.ts
|
||||||
|
+++ b/server/middlewares/csp.ts
|
||||||
|
@@ -59,8 +59,9 @@ export default function createCSPMiddleware(options?: CSPOptions) {
|
||||||
|
|
||||||
|
// Allow to load assets from Vite
|
||||||
|
if (!env.isProduction) {
|
||||||
|
- scriptSrc.push(env.URL.replace(`:${env.PORT}`, ":3001"));
|
||||||
|
- scriptSrc.push("localhost:3001");
|
||||||
|
+ const vitePort = Number(process.env.PORT_VITE) || 3001;
|
||||||
|
+ scriptSrc.push(env.URL.replace(`:${env.PORT}`, `:${vitePort}`));
|
||||||
|
+ scriptSrc.push(`localhost:${vitePort}`);
|
||||||
|
} else {
|
||||||
|
scriptSrc.push(env.URL);
|
||||||
|
}
|
||||||
|
diff --git a/server/routes/app.ts b/server/routes/app.ts
|
||||||
|
index b598752f2..11667c560 100644
|
||||||
|
--- a/server/routes/app.ts
|
||||||
|
+++ b/server/routes/app.ts
|
||||||
|
@@ -22,7 +22,8 @@ import { loadPublicShare } from "@server/commands/shareLoader";
|
||||||
|
|
||||||
|
const readFile = util.promisify(fs.readFile);
|
||||||
|
const entry = "app/index.tsx";
|
||||||
|
-const viteHost = env.URL.replace(`:${env.PORT}`, ":3001");
|
||||||
|
+const vitePort = Number(process.env.PORT_VITE) || 3001;
|
||||||
|
+const viteHost = env.URL.replace(`:${env.PORT}`, `:${vitePort}`);
|
||||||
|
|
||||||
|
let indexHtmlCache: Buffer | undefined;
|
||||||
|
|
||||||
|
diff --git a/server/routes/index.ts b/server/routes/index.ts
|
||||||
|
index 0d304ec50..e32ec2da8 100644
|
||||||
|
--- a/server/routes/index.ts
|
||||||
|
+++ b/server/routes/index.ts
|
||||||
|
@@ -101,14 +101,23 @@ router.get("/locales/:lng.json", async (ctx) => {
|
||||||
|
await send(ctx, path.join(lng, "translation.json"), {
|
||||||
|
setHeaders: (res, _, stats) => {
|
||||||
|
res.setHeader("Last-Modified", formatRFC7231(stats.mtime));
|
||||||
|
- res.setHeader("Cache-Control", `public, max-age=${7 * Day.seconds}`);
|
||||||
|
+ res.setHeader(
|
||||||
|
+ "Cache-Control",
|
||||||
|
+ env.isDevelopment
|
||||||
|
+ ? "no-store"
|
||||||
|
+ : `public, max-age=${7 * Day.seconds}`
|
||||||
|
+ );
|
||||||
|
res.setHeader(
|
||||||
|
"ETag",
|
||||||
|
crypto.createHash("md5").update(stats.mtime.toISOString()).digest("hex")
|
||||||
|
);
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
},
|
||||||
|
- root: path.join(__dirname, "../../shared/i18n/locales"),
|
||||||
|
+ // In dev read the source tree directly so the bind-mounted translation
|
||||||
|
+ // files (and HMR'd changes) are seen without rebuilding.
|
||||||
|
+ root: env.isDevelopment
|
||||||
|
+ ? path.join(__dirname, "../../../shared/i18n/locales")
|
||||||
|
+ : path.join(__dirname, "../../shared/i18n/locales"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
diff --git a/vite.config.ts b/vite.config.ts
|
||||||
|
index 829346243..244308bb3 100644
|
||||||
|
--- a/vite.config.ts
|
||||||
|
+++ b/vite.config.ts
|
||||||
|
@@ -31,7 +31,7 @@ export default () =>
|
||||||
|
publicDir: "./server/static",
|
||||||
|
base: (environment.CDN_URL ?? "") + "/static/",
|
||||||
|
server: {
|
||||||
|
- port: 3001,
|
||||||
|
+ port: Number(process.env.PORT_VITE),
|
||||||
|
host: true,
|
||||||
|
https: httpsConfig,
|
||||||
|
allowedHosts: host ? [host] : undefined,
|
||||||
|
@@ -45,6 +45,27 @@ export default () =>
|
||||||
|
: { strict: true },
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
+ {
|
||||||
|
+ // Custom HMR for translation JSON files. Watches shared/i18n/locales
|
||||||
|
+ // and sends a custom WS event so the client can call
|
||||||
|
+ // i18n.reloadResources() instead of doing a full page reload.
|
||||||
|
+ name: "outline-i18n-hmr",
|
||||||
|
+ apply: "serve",
|
||||||
|
+ configureServer(server) {
|
||||||
|
+ const dir = path.resolve(__dirname, "shared/i18n/locales");
|
||||||
|
+ server.watcher.add(dir);
|
||||||
|
+ server.watcher.on("change", (file) => {
|
||||||
|
+ const m = /locales[\\/]([^\\/]+)[\\/]translation\.json$/.exec(file);
|
||||||
|
+ if (m) {
|
||||||
|
+ server.ws.send({
|
||||||
|
+ type: "custom",
|
||||||
|
+ event: "i18n:update",
|
||||||
|
+ data: { lng: m[1] },
|
||||||
|
+ });
|
||||||
|
+ }
|
||||||
|
+ });
|
||||||
|
+ },
|
||||||
|
+ },
|
||||||
|
react(),
|
||||||
|
// https://vite-pwa-org.netlify.app/
|
||||||
|
VitePWA({
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
diff --git a/server/routes/index.ts b/server/routes/index.ts
|
|
||||||
index 26fbded27..478de1922 100644
|
|
||||||
--- a/server/routes/index.ts
|
|
||||||
+++ b/server/routes/index.ts
|
|
||||||
@@ -103,7 +103,7 @@ router.get("/locales/:lng.json", async (ctx) => {
|
|
||||||
await send(ctx, path.join(lng, "translation.json"), {
|
|
||||||
setHeaders: (res, _, stats) => {
|
|
||||||
res.setHeader("Last-Modified", formatRFC7231(stats.mtime));
|
|
||||||
- res.setHeader("Cache-Control", `public, max-age=${7 * Day.seconds}`);
|
|
||||||
+ res.setHeader("Cache-Control", "no-store");
|
|
||||||
res.setHeader(
|
|
||||||
"ETag",
|
|
||||||
crypto.createHash("md5").update(stats.mtime.toISOString()).digest("hex")
|
|
||||||
diff --git a/vite.config.ts b/vite.config.ts
|
|
||||||
index 32b52d44e..26d611fee 100644
|
|
||||||
--- a/vite.config.ts
|
|
||||||
+++ b/vite.config.ts
|
|
||||||
@@ -45,6 +45,17 @@ export default () =>
|
|
||||||
: { strict: true },
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
+ {
|
|
||||||
+ name: 'reload',
|
|
||||||
+ configureServer(server) {
|
|
||||||
+ const { ws, watcher } = server;
|
|
||||||
+ watcher.on('change', file => {
|
|
||||||
+ if (file.endsWith('build/shared/i18n/locales/ru_RU/translation.json')) {
|
|
||||||
+ ws.send({ type: 'full-reload' });
|
|
||||||
+ }
|
|
||||||
+ });
|
|
||||||
+ },
|
|
||||||
+ },
|
|
||||||
react(),
|
|
||||||
// https://vite-pwa-org.netlify.app/
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
FROM oven/bun:1-alpine
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
RUN apk add --no-cache postgresql17 postgresql17-contrib redis su-exec bash
|
||||||
|
|
||||||
|
ENV PGDATA=/var/lib/postgresql/data
|
||||||
|
RUN mkdir -p "$PGDATA" /run/postgresql /var/log \
|
||||||
|
&& chown -R postgres:postgres "$PGDATA" /run/postgresql
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN bun install --production
|
||||||
|
COPY server.js entrypoint.sh avatar.png ./
|
||||||
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
|
CMD ["./entrypoint.sh"]
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PG_USER="${POSTGRES_USER:-${COMMON:-outline}}"
|
||||||
|
PG_PASS="${POSTGRES_PASSWORD:-${COMMON:-outline}}"
|
||||||
|
PG_DB="${POSTGRES_DB:-${COMMON:-outline}}"
|
||||||
|
|
||||||
|
# named-volume mount comes up root-owned; reclaim it for postgres
|
||||||
|
chown -R postgres:postgres "$PGDATA" /run/postgresql
|
||||||
|
chmod 700 "$PGDATA"
|
||||||
|
|
||||||
|
# ---- Postgres ----
|
||||||
|
if [ ! -s "$PGDATA/PG_VERSION" ]; then
|
||||||
|
echo "==> initdb in $PGDATA"
|
||||||
|
su-exec postgres initdb -D "$PGDATA" --username="$PG_USER" \
|
||||||
|
--auth-local=trust --auth-host=md5 >/dev/null
|
||||||
|
echo "listen_addresses = '*'" >> "$PGDATA/postgresql.conf"
|
||||||
|
echo "host all all 0.0.0.0/0 md5" >> "$PGDATA/pg_hba.conf"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> starting postgres on :5432"
|
||||||
|
su-exec postgres pg_ctl -D "$PGDATA" -l "$PGDATA/postgres.log" -w start
|
||||||
|
|
||||||
|
# ensure password is set (md5 auth needs it for host connections)
|
||||||
|
su-exec postgres psql -U "$PG_USER" -d postgres -c \
|
||||||
|
"ALTER ROLE \"$PG_USER\" WITH LOGIN PASSWORD '$PG_PASS';" >/dev/null
|
||||||
|
|
||||||
|
# create db idempotently
|
||||||
|
su-exec postgres psql -U "$PG_USER" -d postgres -tAc \
|
||||||
|
"SELECT 1 FROM pg_database WHERE datname='$PG_DB'" | grep -q 1 || \
|
||||||
|
su-exec postgres createdb -U "$PG_USER" -O "$PG_USER" "$PG_DB"
|
||||||
|
|
||||||
|
# ---- Redis ----
|
||||||
|
echo "==> starting redis on :6379"
|
||||||
|
redis-server --daemonize yes --bind 0.0.0.0 --port 6379 \
|
||||||
|
--logfile "" --protected-mode no
|
||||||
|
|
||||||
|
# ---- OIDC mock (foreground) ----
|
||||||
|
echo "==> starting bun OIDC mock on :${PORT_OIDC:-8080}"
|
||||||
|
exec bun server.js
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "mock-oidc",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"jose": "^5.9.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// Mock OIDC server with autologin to a single user.
|
||||||
|
// Compatible with IdentityServer-style clients (/connect/*) and generic OIDC clients.
|
||||||
|
// NOT FOR PRODUCTION.
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
|
||||||
|
|
||||||
|
// ---------- Config ----------
|
||||||
|
// ISSUER = internal URL used by server-side relying parties (outline backend),
|
||||||
|
// also signed into JWT `iss` claim.
|
||||||
|
// PUBLIC = browser-facing URL (host:published-port) used in redirect endpoints.
|
||||||
|
// PORT = derived from ISSUER URL so a single var sets where we listen.
|
||||||
|
const ISSUER = process.env.ISSUER || 'http://localhost:8080';
|
||||||
|
const PUBLIC = process.env.PUBLIC_URL || ISSUER;
|
||||||
|
const PORT = parseInt(new URL(ISSUER).port || '8080', 10);
|
||||||
|
const CLIENT_ID = process.env.CLIENT_ID || 'mock-client';
|
||||||
|
const CLIENT_SECRET = process.env.CLIENT_SECRET || 'mock-secret';
|
||||||
|
const TOKEN_TTL = parseInt(process.env.TOKEN_TTL || '3600', 10);
|
||||||
|
const CODE_TTL = parseInt(process.env.CODE_TTL || '60', 10);
|
||||||
|
|
||||||
|
const USER = {
|
||||||
|
sub: process.env.USER_SUB || 'user-1',
|
||||||
|
email: process.env.USER_EMAIL || 'mail@example.com',
|
||||||
|
email_verified: true,
|
||||||
|
name: process.env.USER_NAME || 'Outline',
|
||||||
|
preferred_username: process.env.USER_USERNAME || 'outline',
|
||||||
|
given_name: process.env.USER_GIVEN || 'Outline',
|
||||||
|
family_name: process.env.USER_FAMILY || 'Wiki',
|
||||||
|
roles: (process.env.USER_ROLES || 'admin,user').split(','),
|
||||||
|
picture: `${PUBLIC}/avatar.png`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Keys ----------
|
||||||
|
const { publicKey, privateKey } = await generateKeyPair('RS256');
|
||||||
|
const jwk = { ...(await exportJWK(publicKey)), kid: 'mock-key-1', alg: 'RS256', use: 'sig' };
|
||||||
|
|
||||||
|
// ---------- In-memory stores ----------
|
||||||
|
const codes = new Map();
|
||||||
|
const tokens = new Map();
|
||||||
|
const refreshTokens = new Map();
|
||||||
|
|
||||||
|
// ---------- Helpers ----------
|
||||||
|
const now = () => Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const signJwt = (payload, audience, ttl = TOKEN_TTL) =>
|
||||||
|
new SignJWT(payload)
|
||||||
|
.setProtectedHeader({ alg: 'RS256', kid: jwk.kid, typ: 'JWT' })
|
||||||
|
.setIssuer(ISSUER)
|
||||||
|
.setSubject(USER.sub)
|
||||||
|
.setAudience(audience)
|
||||||
|
.setIssuedAt(now())
|
||||||
|
.setExpirationTime(now() + ttl)
|
||||||
|
.setJti(crypto.randomBytes(16).toString('hex'))
|
||||||
|
.sign(privateKey);
|
||||||
|
|
||||||
|
const extractClientCreds = (req) => {
|
||||||
|
const auth = req.headers.authorization;
|
||||||
|
if (auth?.startsWith('Basic ')) {
|
||||||
|
const decoded = Buffer.from(auth.slice(6), 'base64').toString();
|
||||||
|
const idx = decoded.indexOf(':');
|
||||||
|
return {
|
||||||
|
clientId: decodeURIComponent(decoded.slice(0, idx)),
|
||||||
|
clientSecret: decodeURIComponent(decoded.slice(idx + 1)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { clientId: req.body.client_id, clientSecret: req.body.client_secret };
|
||||||
|
};
|
||||||
|
|
||||||
|
const pkceMatches = (verifier, challenge, method) => {
|
||||||
|
if (!verifier) return false;
|
||||||
|
if (method === 'S256') return crypto.createHash('sha256').update(verifier).digest('base64url') === challenge;
|
||||||
|
return verifier === challenge;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- App ----------
|
||||||
|
const app = express();
|
||||||
|
app.use(cors({ origin: true, credentials: true }));
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Discovery + JWKS ----------
|
||||||
|
app.get('/.well-known/openid-configuration', (_req, res) => res.json({
|
||||||
|
issuer: ISSUER,
|
||||||
|
authorization_endpoint: `${PUBLIC}/connect/authorize`,
|
||||||
|
token_endpoint: `${ISSUER}/connect/token`,
|
||||||
|
userinfo_endpoint: `${ISSUER}/connect/userinfo`,
|
||||||
|
end_session_endpoint: `${PUBLIC}/connect/endsession`,
|
||||||
|
jwks_uri: `${ISSUER}/.well-known/jwks`,
|
||||||
|
response_types_supported: ['code'],
|
||||||
|
response_modes_supported: ['query', 'fragment'],
|
||||||
|
subject_types_supported: ['public'],
|
||||||
|
id_token_signing_alg_values_supported: ['RS256'],
|
||||||
|
scopes_supported: ['openid', 'profile', 'email', 'offline_access'],
|
||||||
|
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic', 'none'],
|
||||||
|
claims_supported: ['sub', 'email', 'email_verified', 'name', 'preferred_username', 'given_name', 'family_name', 'roles'],
|
||||||
|
code_challenge_methods_supported: ['S256', 'plain'],
|
||||||
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get(['/.well-known/jwks', '/.well-known/openid-configuration/jwks', '/jwks'],
|
||||||
|
(_req, res) => res.json({ keys: [jwk] }));
|
||||||
|
|
||||||
|
// ---------- /authorize: AUTOLOGIN ----------
|
||||||
|
app.get(['/connect/authorize', '/authorize'], (req, res) => {
|
||||||
|
const { redirect_uri, state, nonce, scope, response_type,
|
||||||
|
client_id, code_challenge, code_challenge_method } = req.query;
|
||||||
|
const fail = (status, error, description) =>
|
||||||
|
res.status(status).json({ error, error_description: description });
|
||||||
|
|
||||||
|
if (response_type !== 'code') return fail(400, 'unsupported_response_type', 'response_type must be "code"');
|
||||||
|
if (!client_id) return fail(400, 'invalid_request', 'client_id is required');
|
||||||
|
if (!redirect_uri) return fail(400, 'invalid_request', 'redirect_uri is required');
|
||||||
|
|
||||||
|
const code = crypto.randomBytes(32).toString('hex');
|
||||||
|
codes.set(code, {
|
||||||
|
client_id, redirect_uri, nonce,
|
||||||
|
scope: scope || 'openid',
|
||||||
|
code_challenge, code_challenge_method,
|
||||||
|
expires_at: Date.now() + CODE_TTL * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = new URL(redirect_uri);
|
||||||
|
url.searchParams.set('code', code);
|
||||||
|
if (state) url.searchParams.set('state', state);
|
||||||
|
|
||||||
|
console.log(` -> autologin: redirect to ${url.toString()}`);
|
||||||
|
res.redirect(url.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- /token ----------
|
||||||
|
app.post(['/connect/token', '/token'], async (req, res) => {
|
||||||
|
const { grant_type } = req.body;
|
||||||
|
|
||||||
|
if (grant_type === 'authorization_code') {
|
||||||
|
const { code, redirect_uri, code_verifier } = req.body;
|
||||||
|
const { clientId, clientSecret } = extractClientCreds(req);
|
||||||
|
|
||||||
|
const ctx = codes.get(code);
|
||||||
|
codes.delete(code);
|
||||||
|
if (!ctx) return res.status(400).json({ error: 'invalid_grant', error_description: 'unknown code' });
|
||||||
|
if (ctx.expires_at < Date.now()) return res.status(400).json({ error: 'invalid_grant', error_description: 'code expired' });
|
||||||
|
if (ctx.redirect_uri !== redirect_uri) return res.status(400).json({ error: 'invalid_grant', error_description: 'redirect_uri mismatch' });
|
||||||
|
|
||||||
|
if (ctx.code_challenge) {
|
||||||
|
if (!pkceMatches(code_verifier, ctx.code_challenge, ctx.code_challenge_method))
|
||||||
|
return res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed' });
|
||||||
|
} else if (clientId !== CLIENT_ID || clientSecret !== CLIENT_SECRET) {
|
||||||
|
return res.status(401).json({ error: 'invalid_client' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const aud = clientId || CLIENT_ID;
|
||||||
|
const id_token = await signJwt({ ...USER, nonce: ctx.nonce, auth_time: now() }, aud);
|
||||||
|
const access_token = await signJwt({ scope: ctx.scope, client_id: aud, ...USER }, aud);
|
||||||
|
tokens.set(access_token, USER);
|
||||||
|
|
||||||
|
const response = { access_token, id_token, token_type: 'Bearer', expires_in: TOKEN_TTL, scope: ctx.scope };
|
||||||
|
if (ctx.scope.split(' ').includes('offline_access')) {
|
||||||
|
const refresh_token = crypto.randomBytes(32).toString('hex');
|
||||||
|
refreshTokens.set(refresh_token, { client_id: aud, scope: ctx.scope });
|
||||||
|
response.refresh_token = refresh_token;
|
||||||
|
}
|
||||||
|
return res.json(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grant_type === 'refresh_token') {
|
||||||
|
const { refresh_token } = req.body;
|
||||||
|
const ctx = refreshTokens.get(refresh_token);
|
||||||
|
if (!ctx) return res.status(400).json({ error: 'invalid_grant' });
|
||||||
|
|
||||||
|
const id_token = await signJwt({ ...USER, auth_time: now() }, ctx.client_id);
|
||||||
|
const access_token = await signJwt({ scope: ctx.scope, client_id: ctx.client_id, ...USER }, ctx.client_id);
|
||||||
|
tokens.set(access_token, USER);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
access_token, id_token, refresh_token,
|
||||||
|
token_type: 'Bearer', expires_in: TOKEN_TTL, scope: ctx.scope,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(400).json({ error: 'unsupported_grant_type' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- /userinfo ----------
|
||||||
|
app.all(['/connect/userinfo', '/userinfo'], (req, res) => {
|
||||||
|
const auth = req.headers.authorization;
|
||||||
|
const user = auth?.startsWith('Bearer ') ? tokens.get(auth.slice(7)) : null;
|
||||||
|
if (!user) return res.status(401).json({ error: 'invalid_token' });
|
||||||
|
res.json(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- /endsession ----------
|
||||||
|
app.get(['/connect/endsession', '/logout'], (req, res) => {
|
||||||
|
const { post_logout_redirect_uri, state } = req.query;
|
||||||
|
if (post_logout_redirect_uri) {
|
||||||
|
const url = new URL(post_logout_redirect_uri);
|
||||||
|
if (state) url.searchParams.set('state', state);
|
||||||
|
return res.redirect(url.toString());
|
||||||
|
}
|
||||||
|
res.type('html').send('<h1>Logged out</h1>');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Index + health + 404 ----------
|
||||||
|
app.get('/', (_req, res) => res.type('html').send(`<h1>OIDC Mock Server</h1>
|
||||||
|
<p>Autologin as <strong>${USER.email}</strong> (sub: <code>${USER.sub}</code>)</p>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/.well-known/openid-configuration">/.well-known/openid-configuration</a></li>
|
||||||
|
<li><a href="/.well-known/jwks">/.well-known/jwks</a></li>
|
||||||
|
<li><a href="/connect/authorize?response_type=code&client_id=${CLIENT_ID}&redirect_uri=http://localhost:3000/callback&scope=openid+profile+email&state=test">Simulate /authorize</a></li>
|
||||||
|
</ul>
|
||||||
|
<p>Client ID: <code>${CLIENT_ID}</code> · Client Secret: <code>${CLIENT_SECRET}</code></p>`));
|
||||||
|
|
||||||
|
app.get('/avatar.png', (_req, res) => res.sendFile('avatar.png', { root: import.meta.dirname }));
|
||||||
|
|
||||||
|
app.get('/health', (_req, res) => res.json({ status: 'ok', issuer: ISSUER }));
|
||||||
|
|
||||||
|
app.use((req, res) => res.status(404).json({
|
||||||
|
error: 'not_found', path: req.path,
|
||||||
|
hint: 'See /.well-known/openid-configuration',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------- Start ----------
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Mock OIDC listening on :${PORT} issuer=${ISSUER} public=${PUBLIC} client_id=${CLIENT_ID} autologin=${USER.email} (sub=${USER.sub}, roles=${USER.roles.join(',')})`);
|
||||||
|
});
|
||||||
+212
-46
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"New API key": "Новый ключ API",
|
"New API key": "Новый ключ API",
|
||||||
|
"Copy": "Скопировать",
|
||||||
"Delete": "Удалить",
|
"Delete": "Удалить",
|
||||||
"Revoke": "Отозвать",
|
"Revoke": "Отозвать",
|
||||||
"Revoke API key": "Отозвать ключ API",
|
"Revoke API key": "Отозвать ключ API",
|
||||||
@@ -76,7 +77,6 @@
|
|||||||
"Copy public link": "Скопировать публичную ссылку",
|
"Copy public link": "Скопировать публичную ссылку",
|
||||||
"Link copied to clipboard": "Ссылка скопирована в буфер",
|
"Link copied to clipboard": "Ссылка скопирована в буфер",
|
||||||
"Copy link": "Скопировать ссылку",
|
"Copy link": "Скопировать ссылку",
|
||||||
"Copy": "Скопировать",
|
|
||||||
"Duplicate": "Дублировать",
|
"Duplicate": "Дублировать",
|
||||||
"Duplicate document": "Дублировать документ",
|
"Duplicate document": "Дублировать документ",
|
||||||
"Copy document": "Скопировать документ",
|
"Copy document": "Скопировать документ",
|
||||||
@@ -173,6 +173,7 @@
|
|||||||
"Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent.": "Вы уверены? Удаление шаблона <em>{{ templateName }}</em> необратимо.",
|
"Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent.": "Вы уверены? Удаление шаблона <em>{{ templateName }}</em> необратимо.",
|
||||||
"Move to workspace": "Переместить в рабочее пространство",
|
"Move to workspace": "Переместить в рабочее пространство",
|
||||||
"Template moved": "Шаблон перемещён",
|
"Template moved": "Шаблон перемещён",
|
||||||
|
"Couldn't move the template, try again?": "Не удалось переместить шаблон. Попробовать снова?",
|
||||||
"Move to collection": "Переместить в коллекцию",
|
"Move to collection": "Переместить в коллекцию",
|
||||||
"Move template": "Переместить шаблон",
|
"Move template": "Переместить шаблон",
|
||||||
"Print template": "Распечатать шаблон",
|
"Print template": "Распечатать шаблон",
|
||||||
@@ -197,21 +198,21 @@
|
|||||||
"People": "Люди",
|
"People": "Люди",
|
||||||
"Share": "Поделиться",
|
"Share": "Поделиться",
|
||||||
"Workspace": "Рабочее пространство",
|
"Workspace": "Рабочее пространство",
|
||||||
"Recent searches": "Недавние запросы",
|
|
||||||
"currently editing": "сейчас редактируется",
|
"currently editing": "сейчас редактируется",
|
||||||
"currently viewing": "сейчас просматривается",
|
"currently viewing": "сейчас просматривается",
|
||||||
"previously edited": "ранее отредактировано",
|
"previously edited": "ранее отредактировано",
|
||||||
"You": "Вы",
|
"You": "Вы",
|
||||||
"Avatar of {{ name }}": "Аватар {{ name }}",
|
"Avatar of {{ name }}": "Аватар {{ name }}",
|
||||||
"Viewers": "Наблюдатели",
|
"Viewers": "Наблюдатели",
|
||||||
"Collections are used to group documents and choose permissions": "Коллекции используются для группировки документов и выбора разрешений",
|
"Members": "Участники",
|
||||||
"Name": "Имя",
|
|
||||||
"The default access for workspace members, you can share with more users or groups later.": "Доступ по умолчанию для участников рабочего пространства. Позже вы сможете поделиться ими с другими пользователями или группами.",
|
|
||||||
"Advanced options": "Расширенные параметры",
|
|
||||||
"Public document sharing": "Общий доступ к документу",
|
"Public document sharing": "Общий доступ к документу",
|
||||||
"Allow documents within this collection to be shared publicly on the internet.": "Разрешить общий доступ к документам из этой коллекции в Интернете.",
|
"Allow documents within this collection to be shared publicly on the internet.": "Разрешить общий доступ к документам из этой коллекции в Интернете.",
|
||||||
"Commenting": "Комментарий",
|
"Commenting": "Комментарий",
|
||||||
"Allow commenting on documents within this collection.": "Разрешить комментирование документов в этой коллекции.",
|
"Allow commenting on documents within this collection.": "Разрешить комментирование документов в этой коллекции.",
|
||||||
|
"Collections are used to group documents and choose permissions": "Коллекции используются для группировки документов и выбора разрешений",
|
||||||
|
"Name": "Имя",
|
||||||
|
"The default access for workspace members, you can share with more users or groups later.": "Доступ по умолчанию для участников рабочего пространства. Позже вы сможете поделиться ими с другими пользователями или группами.",
|
||||||
|
"Advanced options": "Расширенные параметры",
|
||||||
"Saving": "Сохранение",
|
"Saving": "Сохранение",
|
||||||
"Save": "Сохранить",
|
"Save": "Сохранить",
|
||||||
"Creating": "Создание",
|
"Creating": "Создание",
|
||||||
@@ -239,6 +240,7 @@
|
|||||||
"Install now": "Установить сейчас",
|
"Install now": "Установить сейчас",
|
||||||
"Deleted Collection": "Удаленная коллекция",
|
"Deleted Collection": "Удаленная коллекция",
|
||||||
"Untitled": "Без названия",
|
"Untitled": "Без названия",
|
||||||
|
"Document options": "Параметры документа",
|
||||||
"Unpin": "Открепить",
|
"Unpin": "Открепить",
|
||||||
"Export started": "Экспорт начат",
|
"Export started": "Экспорт начат",
|
||||||
"A link to your file will be sent through email soon": "Ссылка на ваш файл скоро будет отправлена по почте",
|
"A link to your file will be sent through email soon": "Ссылка на ваш файл скоро будет отправлена по почте",
|
||||||
@@ -263,7 +265,6 @@
|
|||||||
"Couldn’t move the document, try again?": "Не удалось переместить документ. Попробовать снова?",
|
"Couldn’t move the document, try again?": "Не удалось переместить документ. Попробовать снова?",
|
||||||
"Move to <em>{{ location }}</em>": "Переместить в <em>{{ location }}</em>",
|
"Move to <em>{{ location }}</em>": "Переместить в <em>{{ location }}</em>",
|
||||||
"Couldn’t move the template, try again?": "Не удалось переместить шаблон. Попробовать снова?",
|
"Couldn’t move the template, try again?": "Не удалось переместить шаблон. Попробовать снова?",
|
||||||
"Document options": "Параметры документа",
|
|
||||||
"New": "Новое",
|
"New": "Новое",
|
||||||
"Only visible to you": "Видно только вам",
|
"Only visible to you": "Видно только вам",
|
||||||
"Draft": "Черновик",
|
"Draft": "Черновик",
|
||||||
@@ -296,16 +297,15 @@
|
|||||||
"Viewed {{ timeAgo }}": "Просмотрено {{ timeAgo }}",
|
"Viewed {{ timeAgo }}": "Просмотрено {{ timeAgo }}",
|
||||||
"File type not supported. Please use PNG, JPG, GIF, or WebP.": "Тип файла не поддерживается. Пожалуйста, используйте PNG, JPG, GIF или WebP.",
|
"File type not supported. Please use PNG, JPG, GIF, or WebP.": "Тип файла не поддерживается. Пожалуйста, используйте PNG, JPG, GIF или WebP.",
|
||||||
"File size too large. Maximum size is {{ size }}.": "Размер файла слишком большой. Максимальный размер — {{ size }}.",
|
"File size too large. Maximum size is {{ size }}.": "Размер файла слишком большой. Максимальный размер — {{ size }}.",
|
||||||
"Please enter a name for the emoji": "Пожалуйста, введите имя для эмодзи.",
|
|
||||||
"Please select an image file": "Пожалуйста, выберите изображение",
|
|
||||||
"Emoji created successfully": "Эмодзи успешно созданы",
|
|
||||||
"Add emoji": "Добавить эмодзи",
|
|
||||||
"Square images with transparent backgrounds work best. If your image is too large, we’ll try to resize it for you.": "Квадратные изображения с прозрачным фоном подходят лучше всего. Если изображение слишком большое, мы попробуем уменьшить его размер за вас.",
|
|
||||||
"Upload an image": "Загрузить изображение",
|
|
||||||
"Click or drag to replace": "Кликните или перетащите, чтобы заменить",
|
"Click or drag to replace": "Кликните или перетащите, чтобы заменить",
|
||||||
"Drop the image here": "Перетащите изображение сюда",
|
"Drop the image here": "Перетащите изображение сюда",
|
||||||
"Click, drop, or paste an image here": "Кликните, перетащите или вставьте изображение сюда",
|
"Click, drop, or paste an image here": "Кликните, перетащите или вставьте изображение сюда",
|
||||||
"PNG, JPG, GIF, or WebP up to {{ size }}": "PNG, JPG, GIF или WebP размером до {{ size }}",
|
"PNG, JPG, GIF, or WebP up to {{ size }}": "PNG, JPG, GIF или WebP размером до {{ size }}",
|
||||||
|
"Please enter a name for the emoji": "Пожалуйста, введите имя для эмодзи.",
|
||||||
|
"Please select an image file": "Пожалуйста, выберите изображение",
|
||||||
|
"Emoji created successfully": "Эмодзи успешно созданы",
|
||||||
|
"Add emoji": "Добавить эмодзи",
|
||||||
|
"Upload an image": "Загрузить изображение",
|
||||||
"Choose a name": "Выберите имя",
|
"Choose a name": "Выберите имя",
|
||||||
"name can only contain lowercase letters, numbers, and underscores.": "имя может состоять только из строчных латинских букв, цифр и подчеркиваний.",
|
"name can only contain lowercase letters, numbers, and underscores.": "имя может состоять только из строчных латинских букв, цифр и подчеркиваний.",
|
||||||
"This emoji will be available as": "Этот эмодзи будет доступен как",
|
"This emoji will be available as": "Этот эмодзи будет доступен как",
|
||||||
@@ -319,15 +319,6 @@
|
|||||||
"our engineers have been notified": "наши инженеры были уведомлены",
|
"our engineers have been notified": "наши инженеры были уведомлены",
|
||||||
"Clear cache + reload": "Очистить кэш и перезагрузить",
|
"Clear cache + reload": "Очистить кэш и перезагрузить",
|
||||||
"Show detail": "Показать детали",
|
"Show detail": "Показать детали",
|
||||||
"{{userName}} archived": "{{userName}} архивирован",
|
|
||||||
"{{userName}} restored": "{{userName}} восстановлен",
|
|
||||||
"{{userName}} deleted": "{{userName}} удален",
|
|
||||||
"{{userName}} added {{addedUserName}}": "{{userName}} добавил {{addedUserName}}",
|
|
||||||
"{{userName}} removed {{removedUserName}}": "{{userName}} удалил {{removedUserName}}",
|
|
||||||
"{{userName}} moved from trash": "{{userName}} перемещен из корзины",
|
|
||||||
"{{userName}} published": "{{userName}} опубликовал",
|
|
||||||
"{{userName}} unpublished": "{{userName}} снял с публикации",
|
|
||||||
"{{userName}} moved": "{{userName}} переместил",
|
|
||||||
"A ZIP file containing the images, and documents in the Markdown format.": "ZIP-архив, содержащий изображения и документы в формате Markdown.",
|
"A ZIP file containing the images, and documents in the Markdown format.": "ZIP-архив, содержащий изображения и документы в формате Markdown.",
|
||||||
"A ZIP file containing the images, and documents as HTML files.": "ZIP-архив, содержащий изображения и документы в формате HTML.",
|
"A ZIP file containing the images, and documents as HTML files.": "ZIP-архив, содержащий изображения и документы в формате HTML.",
|
||||||
"Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Структурированные данные, которые можно использовать для передачи данных в другой совместимый инстанс {{ appName }}.",
|
"Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Структурированные данные, которые можно использовать для передачи данных в другой совместимый инстанс {{ appName }}.",
|
||||||
@@ -422,15 +413,6 @@
|
|||||||
"{{ hours }}h {{ minutes }}m read": "{{ hours }}ч {{ minutes }}м чтения",
|
"{{ hours }}h {{ minutes }}m read": "{{ hours }}ч {{ minutes }}м чтения",
|
||||||
"{{ hours }}h read": "{{ hours }}ч чтения",
|
"{{ hours }}h read": "{{ hours }}ч чтения",
|
||||||
"{{ minutes }}m read": "{{ minutes }}м чтения",
|
"{{ minutes }}m read": "{{ minutes }}м чтения",
|
||||||
"Revision deleted": "Ревизия удалена",
|
|
||||||
"{{count}} people_0": "{{count}} человек",
|
|
||||||
"{{count}} people_1": "{{count}} человека",
|
|
||||||
"{{count}} people_2": "{{count}} человек",
|
|
||||||
"Current version": "Текущая версия",
|
|
||||||
"{{userName}} edited": "{{userName}} отредактировал",
|
|
||||||
"Revision options": "Настройка ревизии",
|
|
||||||
"Results": "Результаты",
|
|
||||||
"No results for {{query}}": "По запросу «{{ query }}» ничего не найдено",
|
|
||||||
"Manage": "Управлять",
|
"Manage": "Управлять",
|
||||||
"All members": "Все участники",
|
"All members": "Все участники",
|
||||||
"Everyone in the workspace": "Все в рабочем пространстве",
|
"Everyone in the workspace": "Все в рабочем пространстве",
|
||||||
@@ -462,6 +444,8 @@
|
|||||||
"Switch to light": "Вкл. светлую тему",
|
"Switch to light": "Вкл. светлую тему",
|
||||||
"Add": "Добавить",
|
"Add": "Добавить",
|
||||||
"Add or invite": "Добавить или пригласить",
|
"Add or invite": "Добавить или пригласить",
|
||||||
|
"Something went wrong": "Что-то пошло не так",
|
||||||
|
"Email address": "Адрес почты",
|
||||||
"Viewer": "Наблюдатель",
|
"Viewer": "Наблюдатель",
|
||||||
"Editor": "Редактор",
|
"Editor": "Редактор",
|
||||||
"Suggestions for invitation": "Предложения для приглашения",
|
"Suggestions for invitation": "Предложения для приглашения",
|
||||||
@@ -498,9 +482,9 @@
|
|||||||
"Expand sidebar": "Развернуть боковую панель",
|
"Expand sidebar": "Развернуть боковую панель",
|
||||||
"Collapse sidebar": "Свернуть боковую панель",
|
"Collapse sidebar": "Свернуть боковую панель",
|
||||||
"Archived collections": "Архивированные коллекции",
|
"Archived collections": "Архивированные коллекции",
|
||||||
|
"Empty": "Пусто",
|
||||||
"New doc": "Новый документ",
|
"New doc": "Новый документ",
|
||||||
"New nested document": "Новый вложенный документ",
|
"New nested document": "Новый вложенный документ",
|
||||||
"Empty": "Пусто",
|
|
||||||
"No collections": "Нет коллекций",
|
"No collections": "Нет коллекций",
|
||||||
"Collapse": "Свернуть",
|
"Collapse": "Свернуть",
|
||||||
"Expand": "Развернуть",
|
"Expand": "Развернуть",
|
||||||
@@ -522,6 +506,7 @@
|
|||||||
"{{ documentName }} cannot be moved within {{ parentDocumentName }}": "{{ documentName }} не может быть перемещён внутри {{ parentDocumentName }}",
|
"{{ documentName }} cannot be moved within {{ parentDocumentName }}": "{{ documentName }} не может быть перемещён внутри {{ parentDocumentName }}",
|
||||||
"You can't reorder documents in an alphabetically sorted collection": "Вы не можете изменить порядок документов в коллекции, отсортированной по алфавиту",
|
"You can't reorder documents in an alphabetically sorted collection": "Вы не можете изменить порядок документов в коллекции, отсортированной по алфавиту",
|
||||||
"{{ documentName }} cannot be moved here": "{{ documentName }} нельзя переместить сюда",
|
"{{ documentName }} cannot be moved here": "{{ documentName }} нельзя переместить сюда",
|
||||||
|
"Integrations": "Интеграции",
|
||||||
"Return to App": "На главную",
|
"Return to App": "На главную",
|
||||||
"Installation": "Установка",
|
"Installation": "Установка",
|
||||||
"Unstar document": "Убрать документ из избранного",
|
"Unstar document": "Убрать документ из избранного",
|
||||||
@@ -565,12 +550,14 @@
|
|||||||
"Height": "Высота",
|
"Height": "Высота",
|
||||||
"Profile picture": "Фото профиля",
|
"Profile picture": "Фото профиля",
|
||||||
"Create a new doc": "Создать новый документ",
|
"Create a new doc": "Создать новый документ",
|
||||||
|
"Create a nested doc": "Создать вложенный документ",
|
||||||
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} не будет уведомлен, так как у него нет доступа к этому документу",
|
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} не будет уведомлен, так как у него нет доступа к этому документу",
|
||||||
"Members of \"{{ groupName }}\" that have access to this document will be notified": "Участники группы «{{ groupName }}», имеющие доступ к этому документу, будут уведомлены",
|
"Members of \"{{ groupName }}\" that have access to this document will be notified": "Участники группы «{{ groupName }}», имеющие доступ к этому документу, будут уведомлены",
|
||||||
"Keep as link": "Сохранить как ссылку",
|
"Keep as link": "Сохранить как ссылку",
|
||||||
"Mention": "Упоминание",
|
"Mention": "Упоминание",
|
||||||
"Embed": "Вставить",
|
"Embed": "Вставить",
|
||||||
"Not supported": "Не поддерживается",
|
"Not supported": "Не поддерживается",
|
||||||
|
"Upload file": "Загрузить файл",
|
||||||
"More options": "Больше параметров",
|
"More options": "Больше параметров",
|
||||||
"Rename": "Переименовать",
|
"Rename": "Переименовать",
|
||||||
"Insert after": "Вставить после",
|
"Insert after": "Вставить после",
|
||||||
@@ -677,7 +664,6 @@
|
|||||||
"Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.": "Вы уверены, что хотите удалить эмодзи <em>{{emojiName}}</em>? Вы больше не сможете использовать его в своих документах или коллекциях.",
|
"Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.": "Вы уверены, что хотите удалить эмодзи <em>{{emojiName}}</em>? Вы больше не сможете использовать его в своих документах или коллекциях.",
|
||||||
"Edit group": "Редактировать группу",
|
"Edit group": "Редактировать группу",
|
||||||
"Delete group": "Удалить группу",
|
"Delete group": "Удалить группу",
|
||||||
"Members": "Участники",
|
|
||||||
"Could not import file": "Не удалось импортировать файл",
|
"Could not import file": "Не удалось импортировать файл",
|
||||||
"Unsubscribed from document": "Отписаться от документа",
|
"Unsubscribed from document": "Отписаться от документа",
|
||||||
"Unsubscribed from collection": "Отменена подписка на коллекцию",
|
"Unsubscribed from collection": "Отменена подписка на коллекцию",
|
||||||
@@ -693,7 +679,6 @@
|
|||||||
"Import": "Импорт",
|
"Import": "Импорт",
|
||||||
"Embeds": "Встраивания",
|
"Embeds": "Встраивания",
|
||||||
"Configure which embed providers are available in the editor.": "Настройте, какие провайдеры встраиваний доступны в редакторе.",
|
"Configure which embed providers are available in the editor.": "Настройте, какие провайдеры встраиваний доступны в редакторе.",
|
||||||
"Integrations": "Интеграции",
|
|
||||||
"Install": "Установить",
|
"Install": "Установить",
|
||||||
"Change name": "Изменить имя",
|
"Change name": "Изменить имя",
|
||||||
"Change email": "Изменить адрес почты",
|
"Change email": "Изменить адрес почты",
|
||||||
@@ -721,6 +706,7 @@
|
|||||||
"Revoking": "Отзыв доступа",
|
"Revoking": "Отзыв доступа",
|
||||||
"Are you sure you want to revoke access?": "Вы уверены, что хотите отозвать доступ?",
|
"Are you sure you want to revoke access?": "Вы уверены, что хотите отозвать доступ?",
|
||||||
"Delete app": "Удалить приложение",
|
"Delete app": "Удалить приложение",
|
||||||
|
"Revision options": "Настройка ревизии",
|
||||||
"Share options": "Настройка доступа",
|
"Share options": "Настройка доступа",
|
||||||
"Headings you add to the document will appear here": "Здесь появятся заголовки, которые вы добавляете в документ",
|
"Headings you add to the document will appear here": "Здесь появятся заголовки, которые вы добавляете в документ",
|
||||||
"Contents": "Содержимое",
|
"Contents": "Содержимое",
|
||||||
@@ -734,8 +720,8 @@
|
|||||||
"published": "опубликованный",
|
"published": "опубликованный",
|
||||||
"edited": "отредактировано",
|
"edited": "отредактировано",
|
||||||
"created the collection": "создана коллекция",
|
"created the collection": "создана коллекция",
|
||||||
"mentioned you in": "упомянул вас в",
|
"mentioned you in": "упомянул(а) вас в",
|
||||||
"mentioned your group in": "упомянул вашу группу в",
|
"mentioned your group in": "упомянул(а) вашу группу в",
|
||||||
"left a comment on": "оставил комментарий в",
|
"left a comment on": "оставил комментарий в",
|
||||||
"resolved a comment on": "отметил комментарий как решённый в",
|
"resolved a comment on": "отметил комментарий как решённый в",
|
||||||
"reacted {{ emoji }} to your comment on": "оставил реакцию {{ emoji }} на ваш комментарий в",
|
"reacted {{ emoji }} to your comment on": "оставил реакцию {{ emoji }} на ваш комментарий в",
|
||||||
@@ -836,8 +822,23 @@
|
|||||||
"Archived": "Архивировано",
|
"Archived": "Архивировано",
|
||||||
"Save draft": "Сохранить черновик",
|
"Save draft": "Сохранить черновик",
|
||||||
"Restore version": "Восстановить версию",
|
"Restore version": "Восстановить версию",
|
||||||
|
"{{userName}} archived": "{{userName}} архивирован",
|
||||||
|
"{{userName}} restored": "{{userName}} восстановлен",
|
||||||
|
"{{userName}} deleted": "{{userName}} удален",
|
||||||
|
"{{userName}} added {{addedUserName}}": "{{userName}} добавил {{addedUserName}}",
|
||||||
|
"{{userName}} removed {{removedUserName}}": "{{userName}} удалил {{removedUserName}}",
|
||||||
|
"{{userName}} moved from trash": "{{userName}} перемещен из корзины",
|
||||||
|
"{{userName}} published": "{{userName}} опубликовал",
|
||||||
|
"{{userName}} unpublished": "{{userName}} снял с публикации",
|
||||||
|
"{{userName}} moved": "{{userName}} переместил",
|
||||||
"Highlight changes": "Выделить изменения",
|
"Highlight changes": "Выделить изменения",
|
||||||
"No history yet": "Истории пока нет",
|
"No history yet": "Истории пока нет",
|
||||||
|
"Revision deleted": "Ревизия удалена",
|
||||||
|
"Current version": "Текущая версия",
|
||||||
|
"{{userName}} edited": "{{userName}} отредактировал",
|
||||||
|
"{{count}} people_0": "{{count}} человек",
|
||||||
|
"{{count}} people_1": "{{count}} человека",
|
||||||
|
"{{count}} people_2": "{{count}} человек",
|
||||||
"Source": "Источник",
|
"Source": "Источник",
|
||||||
"Created": "Создан",
|
"Created": "Создан",
|
||||||
"Imported from {{ source }}": "Импортировано из {{ source }}",
|
"Imported from {{ source }}": "Импортировано из {{ source }}",
|
||||||
@@ -901,7 +902,6 @@
|
|||||||
"Your account has been suspended": "Ваш аккаунт отключён",
|
"Your account has been suspended": "Ваш аккаунт отключён",
|
||||||
"Warning Sign": "Предупреждающий знак",
|
"Warning Sign": "Предупреждающий знак",
|
||||||
"A workspace admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "Администратор рабочего пространства (<em>{{ suspendedContactEmail }}</em>) отключил ваш аккаунт. Свяжитесь с ним напрямую, чтобы повторно активировать аккаунт.",
|
"A workspace admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "Администратор рабочего пространства (<em>{{ suspendedContactEmail }}</em>) отключил ваш аккаунт. Свяжитесь с ним напрямую, чтобы повторно активировать аккаунт.",
|
||||||
"Something went wrong": "Что-то пошло не так",
|
|
||||||
"Sorry, an unknown error occurred loading the page. Please try again or contact support if the issue persists.": "Извините, при загрузке страницы произошла неизвестная ошибка. Попробуйте ещё раз или обратитесь в службу поддержки, если проблема не исчезнет.",
|
"Sorry, an unknown error occurred loading the page. Please try again or contact support if the issue persists.": "Извините, при загрузке страницы произошла неизвестная ошибка. Попробуйте ещё раз или обратитесь в службу поддержки, если проблема не исчезнет.",
|
||||||
"Created by me": "Созданные мной",
|
"Created by me": "Созданные мной",
|
||||||
"Weird, this shouldn't ever be empty": "Здесь будут появляться недавно обновлённые документы",
|
"Weird, this shouldn't ever be empty": "Здесь будут появляться недавно обновлённые документы",
|
||||||
@@ -1067,6 +1067,7 @@
|
|||||||
"Any time": "За любое время",
|
"Any time": "За любое время",
|
||||||
"Remove document filter": "Удалить фильтр документа",
|
"Remove document filter": "Удалить фильтр документа",
|
||||||
"Any status": "Любой статус",
|
"Any status": "Любой статус",
|
||||||
|
"Recent searches": "Недавние запросы",
|
||||||
"Remove search": "Убрать поиск",
|
"Remove search": "Убрать поиск",
|
||||||
"Relevance": "Релевантность",
|
"Relevance": "Релевантность",
|
||||||
"Newest": "Новее",
|
"Newest": "Новее",
|
||||||
@@ -1353,7 +1354,6 @@
|
|||||||
"Photo": "Фото",
|
"Photo": "Фото",
|
||||||
"Choose a photo or image to represent yourself.": "Выберите фотографию или изображение, чтобы представить себя.",
|
"Choose a photo or image to represent yourself.": "Выберите фотографию или изображение, чтобы представить себя.",
|
||||||
"This could be your real name, or a nickname — however you’d like people to refer to you.": "Это может быть ваше настоящее имя или псевдоним — как бы вы хотели, чтобы люди обращались к вам.",
|
"This could be your real name, or a nickname — however you’d like people to refer to you.": "Это может быть ваше настоящее имя или псевдоним — как бы вы хотели, чтобы люди обращались к вам.",
|
||||||
"Email address": "Адрес почты",
|
|
||||||
"Members and guests": "Участники и гости",
|
"Members and guests": "Участники и гости",
|
||||||
"No one": "Никто",
|
"No one": "Никто",
|
||||||
"Are you sure you want to require invites?": "Вы уверены, что хотите требовать приглашений?",
|
"Are you sure you want to require invites?": "Вы уверены, что хотите требовать приглашений?",
|
||||||
@@ -1580,6 +1580,9 @@
|
|||||||
"Script name": "Имя скрипта",
|
"Script name": "Имя скрипта",
|
||||||
"The name of the script file that Umami uses to track analytics.": "Имя файла скрипта, который Umami использует для отслеживания аналитики.",
|
"The name of the script file that Umami uses to track analytics.": "Имя файла скрипта, который Umami использует для отслеживания аналитики.",
|
||||||
"An ID that uniquely identifies the website in your Umami instance.": "Идентификатор, который однозначно идентифицирует сайт в вашем инстансе Umami.",
|
"An ID that uniquely identifies the website in your Umami instance.": "Идентификатор, который однозначно идентифицирует сайт в вашем инстансе Umami.",
|
||||||
|
"New webhook": "Новый вебхук",
|
||||||
|
"Edit webhook": "Редактировать вебхук",
|
||||||
|
"Delete webhook": "Удалить вебхук",
|
||||||
"Are you sure you want to delete the {{ name }} webhook?": "Вы уверены, что хотите удалить webhook {{ name }}?",
|
"Are you sure you want to delete the {{ name }} webhook?": "Вы уверены, что хотите удалить webhook {{ name }}?",
|
||||||
"Webhook updated": "Вебхук обновлен",
|
"Webhook updated": "Вебхук обновлен",
|
||||||
"Update": "Обновить",
|
"Update": "Обновить",
|
||||||
@@ -1591,14 +1594,9 @@
|
|||||||
"Subscribe to all events, groups, or individual events. We recommend only subscribing to the minimum amount of events that your application needs to function.": "Подпишитесь на все события, группы или отдельные события. Мы рекомендуем подписаться на минимальное количество событий, которое требуется для функционирования вашего приложения.",
|
"Subscribe to all events, groups, or individual events. We recommend only subscribing to the minimum amount of events that your application needs to function.": "Подпишитесь на все события, группы или отдельные события. Мы рекомендуем подписаться на минимальное количество событий, которое требуется для функционирования вашего приложения.",
|
||||||
"All events": "Все события",
|
"All events": "Все события",
|
||||||
"All {{ groupName }} events": "Все события {{ groupName }}",
|
"All {{ groupName }} events": "Все события {{ groupName }}",
|
||||||
"Delete webhook": "Удалить вебхук",
|
|
||||||
"Subscribed events": "События, на которые оформлена подписка",
|
|
||||||
"Edit webhook": "Редактировать вебхук",
|
|
||||||
"Webhook created": "Вебхук создан",
|
"Webhook created": "Вебхук создан",
|
||||||
"Webhooks": "Вебхуки",
|
"Webhooks": "Вебхуки",
|
||||||
"New webhook": "Новый вебхук",
|
|
||||||
"Webhooks can be used to notify your application when events happen in {{appName}}. Events are sent as a https request with a JSON payload in near real-time.": "Вебхуки можно использовать для уведомления вашего приложения о событиях, происходящих в {{appName}}. События отправляются в виде HTTPS-запроса с JSON-полезной нагрузкой практически в реальном времени.",
|
"Webhooks can be used to notify your application when events happen in {{appName}}. Events are sent as a https request with a JSON payload in near real-time.": "Вебхуки можно использовать для уведомления вашего приложения о событиях, происходящих в {{appName}}. События отправляются в виде HTTPS-запроса с JSON-полезной нагрузкой практически в реальном времени.",
|
||||||
"Inactive": "Неактивно",
|
|
||||||
"Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.": "Zapier — это платформа, которая позволяет {{appName}} легко интегрироваться с тысячами других бизнес-инструментов. Автоматизируйте рабочие процессы, синхронизируйте данные и многое другое.",
|
"Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.": "Zapier — это платформа, которая позволяет {{appName}} легко интегрироваться с тысячами других бизнес-инструментов. Автоматизируйте рабочие процессы, синхронизируйте данные и многое другое.",
|
||||||
"Never logged in": "Никогда не входил",
|
"Never logged in": "Никогда не входил",
|
||||||
"Online now": "Сейчас в сети",
|
"Online now": "Сейчас в сети",
|
||||||
@@ -1614,7 +1612,175 @@
|
|||||||
"Open": "Открыть",
|
"Open": "Открыть",
|
||||||
"Loading": "Загрузка",
|
"Loading": "Загрузка",
|
||||||
"Error loading data": "Не удалось загрузить данные",
|
"Error loading data": "Не удалось загрузить данные",
|
||||||
"Couldn't move the template, try again?": "Не удалось переместить шаблон. Попробовать снова?",
|
"API key copied": "Ключ API скопирован",
|
||||||
"Create a nested doc": "Создать вложенный документ",
|
"Search results": "Результаты поиска",
|
||||||
"Upload file": "Загрузить файл"
|
"Managers": "Менеджеры",
|
||||||
|
"Manage templates": "Управление шаблонами",
|
||||||
|
"Choose who can create and edit templates in this collection.": "Выберите, кто может создавать и редактировать шаблоны в этой коллекции.",
|
||||||
|
"Collection options": "Параметры коллекции",
|
||||||
|
"Square images with transparent backgrounds work best. If your image is too large, we'll try to resize it for you.": "Лучше всего подходят квадратные изображения с прозрачным фоном. Если изображение слишком большое, мы попробуем уменьшить его размер.",
|
||||||
|
"Emoji replaced": "Эмодзи заменено",
|
||||||
|
"Upload a new image to replace the current one for <em>{{emojiName}}</em>. All existing uses of this emoji will be updated automatically.": "Загрузите новое изображение, чтобы заменить текущее для <em>{{emojiName}}</em>. Все существующие случаи использования этого эмодзи будут обновлены автоматически.",
|
||||||
|
"Email subscriptions": "Подписки на уведомления по почте",
|
||||||
|
"Allow viewers to subscribe and receive email notifications when documents are updated": "Разрешить наблюдателям подписываться и получать уведомления по почте при обновлении документов",
|
||||||
|
"Subscribe to updates": "Подписаться на обновления",
|
||||||
|
"Check your email to confirm your subscription": "Проверьте почту, чтобы подтвердить подписку",
|
||||||
|
"Get notified when this document is updated": "Получать уведомления при обновлении этого документа",
|
||||||
|
"Allow viewers to subscribe and receive email notifications when this document is updated": "Разрешить наблюдателям подписываться и получать уведомления по почте при обновлении этого документа",
|
||||||
|
"Recent": "Недавние",
|
||||||
|
"Subscription successful": "Подписка оформлена",
|
||||||
|
"Unsubscribed": "Отписка выполнена",
|
||||||
|
"Previous version": "Предыдущая версия",
|
||||||
|
"Compare to": "Сравнить с",
|
||||||
|
"Personal keys": "Личные ключи",
|
||||||
|
"Could not load API keys": "Не удалось загрузить ключи API",
|
||||||
|
"Key": "Ключ",
|
||||||
|
"Created by": "Создал",
|
||||||
|
"Never": "Никогда",
|
||||||
|
"Expires": "Истекает",
|
||||||
|
"Additional guidance": "Дополнительные указания",
|
||||||
|
"You can use these optional instructions to tell MCP clients how to use your knowledge base.": "Вы можете использовать эти необязательные инструкции, чтобы сообщить MCP-клиентам, как использовать вашу базу знаний.",
|
||||||
|
"New passkey added to your {{ appName }} account": "В аккаунт {{ appName }} добавлен новый ключ доступа",
|
||||||
|
"A new passkey was created for your account.": "Для вашего аккаунта создан новый ключ доступа.",
|
||||||
|
"New Passkey Created": "Создан новый ключ доступа",
|
||||||
|
"A new passkey has been added to your {{ appName }} account": "К вашему аккаунту {{ appName }} добавлен новый ключ доступа",
|
||||||
|
"Passkeys provide a secure, passwordless way to sign in to your account. If you did not create this passkey, please review your account security settings immediately.": "Ключи доступа обеспечивают безопасный вход в аккаунт без пароля. Если вы не создавали этот ключ, немедленно проверьте настройки безопасности своего аккаунта.",
|
||||||
|
"You can manage your passkeys at any time": "Вы можете управлять своими ключами доступа в любое время",
|
||||||
|
"If you have any concerns about your account security, please contact a workspace admin.": "Если у вас есть опасения по поводу безопасности аккаунта, обратитесь к администратору рабочего пространства.",
|
||||||
|
"Manage Passkeys": "Управление ключами доступа",
|
||||||
|
"Webhook": "Вебхук",
|
||||||
|
"Could not load webhooks": "Не удалось загрузить вебхуки",
|
||||||
|
"Delayed notification": "Отложенное уведомление",
|
||||||
|
"“{{ collectionName }}” created": "«{{ collectionName }}» создана",
|
||||||
|
"{{ userName }} created a collection": "{{ userName }} создал(а) коллекцию",
|
||||||
|
"{{ userName }} created the collection “{{ collectionName }}”": "{{ userName }} создал(а) коллекцию «{{ collectionName }}»",
|
||||||
|
"Open Collection": "Открыть коллекцию",
|
||||||
|
"View Collection": "Просмотр коллекции",
|
||||||
|
"{{ userName }} created the collection “{{ collectionName }}”.": "{{ userName }} создал(а) коллекцию «{{ collectionName }}».",
|
||||||
|
"Unsubscribe from these emails": "Отписаться от этих писем",
|
||||||
|
"{{ actorName }} invited you to the “{{ collectionName }}” collection": "{{ actorName }} пригласил(а) вас в коллекцию «{{ collectionName }}»",
|
||||||
|
"{{ actorName }} invited you to a collection": "{{ actorName }} пригласил(а) вас в коллекцию",
|
||||||
|
"{{ actorName }} invited you to the “{{ collectionName }}” collection.": "{{ actorName }} пригласил(а) вас в коллекцию «{{ collectionName }}».",
|
||||||
|
"view and edit": "просмотр и редактирование",
|
||||||
|
"manage": "управление",
|
||||||
|
"view": "просмотр",
|
||||||
|
"{{ actorName }} invited you to {{ permission }} documents in the": "{{ actorName }} пригласил(а) вас на {{ permission }} документов в",
|
||||||
|
"Re": "Re",
|
||||||
|
"New comment on “{{ documentTitle }}” - {{ trimmedText }}": "Новый комментарий к «{{ documentTitle }}» — {{ trimmedText }}",
|
||||||
|
"{{ actorName }} replied in a thread": "{{ actorName }} ответил(а) в обсуждении",
|
||||||
|
"{{ actorName }} commented on the document": "{{ actorName }} оставил(а) комментарий к документу",
|
||||||
|
"{{ actorName }} replied to a thread in “{{ documentTitle }}”": "{{ actorName }} ответил(а) в обсуждении в «{{ documentTitle }}»",
|
||||||
|
"{{ actorName }} commented on “{{ documentTitle }}”": "{{ actorName }} оставил(а) комментарий в «{{ documentTitle }}»",
|
||||||
|
"in the {{ collectionName }} collection": "в коллекции {{ collectionName }}",
|
||||||
|
"Open Thread": "Открыть обсуждение",
|
||||||
|
"View Thread": "Просмотр обсуждения",
|
||||||
|
"{{ actorName }} replied to a thread in": "{{ actorName }} ответил(а) в обсуждении в",
|
||||||
|
"{{ actorName }} commented on": "{{ actorName }} оставил(а) комментарий в",
|
||||||
|
"Mentioned you in “{{ documentTitle }}”": "Упомянул(а) вас в «{{ documentTitle }}»",
|
||||||
|
"{{ actorName }} mentioned you in a thread": "{{ actorName }} упомянул(а) вас в обсуждении",
|
||||||
|
"{{ actorName }} mentioned you in a comment on “{{ documentTitle }}”": "{{ actorName }} упомянул(а) вас в комментарии в «{{ documentTitle }}»",
|
||||||
|
"{{ actorName }} mentioned you in a comment on": "{{ actorName }} упомянул(а) вас в комментарии в",
|
||||||
|
"Resolved a comment thread in “{{ documentTitle }}”": "Отметил(а) ветку комментариев как решённую в «{{ documentTitle }}»",
|
||||||
|
"{{ actorName }} resolved a comment thread": "{{ actorName }} отметил(а) ветку комментариев как решённую",
|
||||||
|
"{{ actorName }} resolved a comment thread on “{{ documentTitle }}”": "{{ actorName }} отметил(а) ветку комментариев как решённую в «{{ documentTitle }}»",
|
||||||
|
"{{ actorName }} resolved a comment on": "{{ actorName }} отметил(а) комментарий как решённый в",
|
||||||
|
"Your workspace deletion request": "Ваш запрос на удаление рабочего пространства",
|
||||||
|
"Your requested workspace deletion code": "Запрошенный код для удаления рабочего пространства",
|
||||||
|
"You requested to permanently delete your {{ appName }} workspace. Please enter the code below to confirm your workspace deletion.": "Вы запросили окончательное удаление рабочего пространства {{ appName }}. Введите код ниже, чтобы подтвердить удаление.",
|
||||||
|
"Your email update request": "Ваш запрос на изменение адреса почты",
|
||||||
|
"Here’s your email change confirmation.": "Вот ваше подтверждение изменения адреса почты.",
|
||||||
|
"You requested to update your {{ appName }} account email. Please follow the link below to confirm the change from {{ previous }} to {{ to }}.": "Вы запросили изменение адреса почты аккаунта {{ appName }}. Перейдите по ссылке ниже, чтобы подтвердить изменение с {{ previous }} на {{ to }}.",
|
||||||
|
"You requested to update your {{ appName }} account email. Please follow the link below to confirm the change to {{ to }}.": "Вы запросили изменение адреса почты аккаунта {{ appName }}. Перейдите по ссылке ниже, чтобы подтвердить изменение на {{ to }}.",
|
||||||
|
"You requested to update your {{ appName }} account email. Please click below to confirm the change from {{ previous }} to": "Вы запросили изменение адреса почты аккаунта {{ appName }}. Нажмите ниже, чтобы подтвердить изменение с {{ previous }} на",
|
||||||
|
"You requested to update your {{ appName }} account email. Please click below to confirm the change to": "Вы запросили изменение адреса почты аккаунта {{ appName }}. Нажмите ниже, чтобы подтвердить изменение на",
|
||||||
|
"Confirm Change": "Подтвердить изменение",
|
||||||
|
"Your account deletion request": "Ваш запрос на удаление аккаунта",
|
||||||
|
"Your requested account deletion code": "Запрошенный код для удаления аккаунта",
|
||||||
|
"You requested to permanently delete your {{ appName }} user account in the {{ teamName }} workspace. Please enter the code below to confirm your account deletion.": "Вы запросили окончательное удаление аккаунта {{ appName }} в рабочем пространстве {{ teamName }}. Введите код ниже, чтобы подтвердить удаление аккаунта.",
|
||||||
|
"You requested to permanently delete your {{ appName }} user account in the": "Вы запросили окончательное удаление аккаунта {{ appName }} в",
|
||||||
|
"workspace. Please enter the code below to confirm your account deletion.": "рабочем пространстве. Введите код ниже, чтобы подтвердить удаление аккаунта.",
|
||||||
|
"{{ actorName }} mentioned you": "{{ actorName }} упомянул(а) вас",
|
||||||
|
"You were mentioned": "Вас упомянули",
|
||||||
|
"{{ actorName }} mentioned you in the document “{{ documentTitle }}”.": "{{ actorName }} упомянул(а) вас в документе «{{ documentTitle }}».",
|
||||||
|
"Open Document": "Открыть документ",
|
||||||
|
"View Document": "Просмотр документа",
|
||||||
|
"{{ actorName }} mentioned you in the document": "{{ actorName }} упомянул(а) вас в документе",
|
||||||
|
"updated": "обновил(а)",
|
||||||
|
"“{{ documentTitle }}” {{ eventName }}": "«{{ documentTitle }}» — {{ eventName }}",
|
||||||
|
"{{ actorName }} {{ eventName }} a document": "{{ actorName }} {{ eventName }} документ",
|
||||||
|
"\"{{ documentTitle }}\" {{ eventName }}": "\"{{ documentTitle }}\" {{ eventName }}",
|
||||||
|
"{{ actorName }} {{ eventName }} the document \"{{ documentTitle }}\"": "{{ actorName }} {{ eventName }} документ \"{{ documentTitle }}\"",
|
||||||
|
"{{ actorName }} {{ eventName }} the document": "{{ actorName }} {{ eventName }} документ",
|
||||||
|
"Unsubscribe from this doc": "Отписаться от этого документа",
|
||||||
|
"{{ actorName }} shared “{{ documentTitle }}” with you": "{{ actorName }} поделил(а)ся с вами «{{ documentTitle }}»",
|
||||||
|
"{{ actorName }} shared a document": "{{ actorName }} поделил(а)ся документом",
|
||||||
|
"{{ actorName }} shared “{{ documentTitle }}” with you.": "{{ actorName }} поделил(а)ся с вами «{{ documentTitle }}».",
|
||||||
|
"edit": "редактирование",
|
||||||
|
"{{ actorName }} invited you to {{ permission }} the": "{{ actorName }} пригласил(а) вас на {{ permission }}",
|
||||||
|
"Your requested export": "Запрошенный вами экспорт",
|
||||||
|
"Sorry, your requested data export has failed": "К сожалению, экспорт данных не выполнен",
|
||||||
|
"Your Data Export": "Экспорт ваших данных",
|
||||||
|
"Sorry, your requested data export has failed, please visit the admin section to try again – if the problem persists please contact support.": "К сожалению, экспорт данных не выполнен. Перейдите в раздел администратора, чтобы повторить попытку — если проблема сохраняется, обратитесь в поддержку.",
|
||||||
|
"Sorry, your requested data export has failed, please visit the": "К сожалению, экспорт данных не выполнен. Перейдите в",
|
||||||
|
"admin section": "раздел администратора",
|
||||||
|
"to try again – if the problem persists please contact support.": "чтобы повторить попытку — если проблема сохраняется, обратитесь в поддержку.",
|
||||||
|
"Go to export": "Перейти к экспорту",
|
||||||
|
"Here's your request data export from {{ appName }}": "Вот ваш запрошенный экспорт данных из {{ appName }}",
|
||||||
|
"Your requested data export is complete, you can download from the link below in a browser that is logged into your account.": "Запрошенный экспорт данных готов. Скачайте его по ссылке ниже в браузере, в котором выполнен вход в ваш аккаунт.",
|
||||||
|
"Download export": "Скачать экспорт",
|
||||||
|
"Your requested data export is complete - you can download from the link below in a browser that is logged into your account.": "Запрошенный экспорт данных готов — скачайте его по ссылке ниже в браузере, в котором выполнен вход в ваш аккаунт.",
|
||||||
|
"The {{ groupName }} group was mentioned in “{{ documentTitle }}”": "Группа {{ groupName }} упомянута в «{{ documentTitle }}»",
|
||||||
|
"{{ actorName }} mentioned the “{{ groupName }}” group in a thread": "{{ actorName }} упомянул(а) группу «{{ groupName }}» в обсуждении",
|
||||||
|
"{{ actorName }} mentioned the “{{ groupName }}” group in a comment on “{{ documentTitle }}”": "{{ actorName }} упомянул(а) группу «{{ groupName }}» в комментарии в «{{ documentTitle }}»",
|
||||||
|
"{{ actorName }} mentioned the “{{ groupName }}” group in a comment on": "{{ actorName }} упомянул(а) группу «{{ groupName }}» в комментарии в",
|
||||||
|
"{{ actorName }} mentioned the “{{ groupName }}” group": "{{ actorName }} упомянул(а) группу «{{ groupName }}»",
|
||||||
|
"{{ actorName }} mentioned the “{{ groupName }}” group in the document “{{ documentTitle }}”.": "{{ actorName }} упомянул(а) группу «{{ groupName }}» в документе «{{ documentTitle }}».",
|
||||||
|
"Your group was mentioned": "Ваша группа упомянута",
|
||||||
|
"{{ actorName }} mentioned the \"{{ groupName }}\" group in the document": "{{ actorName }} упомянул(а) группу \"{{ groupName }}\" в документе",
|
||||||
|
"{{ invitedName }} has joined your {{ appName }} team": "{{ invitedName }} присоединил(а)ся к вашей команде в {{ appName }}",
|
||||||
|
"Great news, {{ invitedName }}, accepted your invitation": "Отличные новости — {{ invitedName }} принял(а) ваше приглашение",
|
||||||
|
"Great news, {{ invitedName }} just accepted your invitation and has created an account. You can now start collaborating on documents.": "Отличные новости — {{ invitedName }} только что принял(а) ваше приглашение и создал(а) аккаунт. Теперь вы можете совместно работать над документами.",
|
||||||
|
"Open {{ appName }}": "Открыть {{ appName }}",
|
||||||
|
"{{ invitedName }} has joined your team": "{{ invitedName }} присоединил(а)ся к вашей команде",
|
||||||
|
"{{ actorName }} invited you to join {{ teamName }}’s workspace": "{{ actorName }} пригласил(а) вас в рабочее пространство {{ teamName }}",
|
||||||
|
"{{ appName }} is a place for your team to build and share knowledge.": "{{ appName }} — место, где ваша команда создаёт и делится знаниями.",
|
||||||
|
"Join {{ teamName }} on {{ appName }}": "Присоединиться к {{ teamName }} в {{ appName }}",
|
||||||
|
"has invited you to join {{ appName }}, a place for your team to build and share knowledge.": "пригласил(а) вас в {{ appName }} — место, где ваша команда создаёт и делится знаниями.",
|
||||||
|
"Join now": "Присоединиться",
|
||||||
|
"Reminder": "Напоминание",
|
||||||
|
"This is just a quick reminder that {{ actorName }} {{ actorEmail }} invited you to join them in the {{ teamName }} team on {{ appName }}, a place for your team to build and share knowledge.": "Краткое напоминание: {{ actorName }} {{ actorEmail }} пригласил(а) вас в команду {{ teamName }} в {{ appName }} — месте, где ваша команда создаёт и делится знаниями.",
|
||||||
|
"We only send a reminder once.": "Напоминание отправляется только один раз.",
|
||||||
|
"If you haven't signed up yet, you can do so here": "Если вы ещё не зарегистрировались, это можно сделать здесь",
|
||||||
|
"\"{{ documentTitle }}\" updated": "\"{{ documentTitle }}\" обновлён",
|
||||||
|
"\"{{ documentTitle }}\" has been updated.": "\"{{ documentTitle }}\" обновлён.",
|
||||||
|
"A document you subscribed to has been updated.": "Документ, на который вы подписаны, был обновлён.",
|
||||||
|
"Click below to view the latest version.": "Нажмите ниже, чтобы посмотреть последнюю версию.",
|
||||||
|
"Confirm your subscription": "Подтвердите подписку",
|
||||||
|
"Confirm your subscription to receive updates when \"{{ documentTitle }}\" changes.": "Подтвердите подписку, чтобы получать обновления при изменениях в \"{{ documentTitle }}\".",
|
||||||
|
"You requested to receive email notifications when \"{{ documentTitle }}\" is updated on {{ appName }}. Please confirm your subscription by following the link below.": "Вы запросили уведомления по почте об обновлениях \"{{ documentTitle }}\" в {{ appName }}. Подтвердите подписку, перейдя по ссылке ниже.",
|
||||||
|
"Confirm Subscription": "Подтвердить подписку",
|
||||||
|
"This link will expire in 24 hours.": "Срок действия ссылки — 24 часа.",
|
||||||
|
"You requested to receive email notifications when \"{{ documentTitle }}\" is updated on {{ appName }}.": "Вы запросили уведомления по почте об обновлениях \"{{ documentTitle }}\" в {{ appName }}.",
|
||||||
|
"Please confirm your subscription by clicking the button below.": "Подтвердите подписку, нажав кнопку ниже.",
|
||||||
|
"Magic Sign-in Link": "Ссылка для входа",
|
||||||
|
"Sign in verification code": "Код подтверждения входа",
|
||||||
|
"Here’s your link to signin to {{ appName }}.": "Вот ваша ссылка для входа в {{ appName }}.",
|
||||||
|
"Use the link below to sign in": "Перейдите по ссылке ниже, чтобы войти",
|
||||||
|
"If the link expired you can request a new one from your team's signin page at": "Если срок действия ссылки истёк, запросите новую на странице входа вашей команды по адресу",
|
||||||
|
"Enter this verification code": "Введите этот код подтверждения",
|
||||||
|
"If the code expired you can request a new one from your team's signin page at": "Если срок действия кода истёк, запросите новый на странице входа вашей команды по адресу",
|
||||||
|
"Click the button below to sign in to {{ appName }}.": "Нажмите кнопку ниже, чтобы войти в {{ appName }}.",
|
||||||
|
"If the link expired you can request a new one from your team's sign-in page at": "Если срок действия ссылки истёк, запросите новую на странице входа вашей команды по адресу",
|
||||||
|
"Sign-in Code": "Код для входа",
|
||||||
|
"Enter this code on your team's sign-in page to continue.": "Введите этот код на странице входа вашей команды, чтобы продолжить.",
|
||||||
|
"If the code expired you can request a new one from your team's sign-in page at": "Если срок действия кода истёк, запросите новый на странице входа вашей команды по адресу",
|
||||||
|
"Webhook disabled": "Вебхук отключён",
|
||||||
|
"Your webhook ({{ webhookName }}) has been disabled": "Ваш вебхук ({{ webhookName }}) отключён",
|
||||||
|
"Your webhook ({{ webhookName }}) has been automatically disabled due to a high failure rate in recent delivery attempts. You can re-enable by editing the webhook.": "Ваш вебхук ({{ webhookName }}) автоматически отключён из-за высокого процента ошибок при последних попытках доставки. Включить его снова можно, отредактировав вебхук.",
|
||||||
|
"Webhook settings": "Настройки вебхука",
|
||||||
|
"Welcome to {{ appName }}!": "Добро пожаловать в {{ appName }}!",
|
||||||
|
"To get started, head to the home screen and try creating a collection to help document your processes, create playbooks, or plan your team's work.": "Для начала перейдите на главный экран и попробуйте создать коллекцию, чтобы документировать процессы, создавать плейбуки или планировать работу команды.",
|
||||||
|
"Or, learn more about everything {{ appName }} can do in the guide": "Или узнайте больше обо всех возможностях {{ appName }} в руководстве",
|
||||||
|
"Or, learn more about everything {{ appName }} can do in": "Или узнайте больше обо всех возможностях {{ appName }} в",
|
||||||
|
"the guide": "руководстве"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user