* 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:
Evgeny
2026-05-01 09:14:01 +05:00
committed by GitHub
parent b3bda3622c
commit d0abf84aa8
17 changed files with 772 additions and 330 deletions
+5 -10
View File
@@ -1,11 +1,6 @@
APP_PATH=/opt/outline
SRC_PATH=./outline
COMPOSE_PROFILES=dev
ADDRESS=localhost
PORT_OUTLINE=10240
PORT_OIDC=10241
PORT_REDIS=10242
PORT_POSTGRES=10243
COMMON=outline
SECRET=deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef
# PORT=10240
# PORT_OIDC=10241
# PORT_VITE=10242
# SECRET=deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef # openssl rand -hex 32
+13 -15
View File
@@ -9,8 +9,7 @@ on:
- .github/workflows/**
- outline/**
- translation/ru.json
- patches/**
- Dockerfile.prod
- Dockerfile
workflow_dispatch:
concurrency:
@@ -42,16 +41,16 @@ jobs:
submodules: recursive
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ github.actor }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub CR
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -65,7 +64,7 @@ jobs:
echo "version=$version" | tee -a $GITHUB_OUTPUT
- name: Set Metadata
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
id: metadata
with:
images: |
@@ -73,13 +72,12 @@ jobs:
ghcr.io/${{ github.repository }}
- name: Build Image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
id: build
with:
cache-from: type=gha,scope=build-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.arch }}
platforms: linux/${{ matrix.arch }}
file: Dockerfile.prod
build-args: |
APP_PATH=/opt/outline
SRC_PATH=./outline
@@ -93,7 +91,7 @@ jobs:
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload Digests
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v7
with:
name: digests-linux-${{ matrix.arch }}
path: ${{ runner.temp }}/digests/*
@@ -111,30 +109,30 @@ jobs:
version: ${{ needs.build.outputs.version }}
steps:
- name: Download Digests
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ github.actor }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub CR
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Set Metadata
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ github.repository }}
@@ -153,7 +151,7 @@ jobs:
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
- name: Create Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
if: ${{ github.ref == 'refs/heads/master' }}
with:
name: ${{ env.version }}
+34 -13
View File
@@ -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 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
@@ -10,16 +13,34 @@ 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
npx yarn concurrently -n "dev,i18n" \
"yarn dev:watch" \
"yarn nodemon \
--watch './shared/i18n/locales/ru_RU' \
--exec 'yarn build:i18n'"
EOF
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:24.15.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}
STOPSIGNAL SIGKILL
ENTRYPOINT ["bash", "/entrypoint.sh"]
HEALTHCHECK --interval=1m CMD curl -fs localhost:${PORT}/_health | grep -q OK || exit 1
CMD ["node", "build/server/index.js"]
+24
View File
@@ -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"]
-43
View File
@@ -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"]
+3 -3
View File
@@ -20,8 +20,8 @@
```yaml
services:
outline:
image: flameshikari/outline-ru:1.6.1
# image: ghcr.io/flameshikari/outline-ru:1.6.1
image: flameshikari/outline-ru:1.7.0
# image: ghcr.io/flameshikari/outline-ru:1.7.0
env_file: ./docker.env
expose:
- 3000
@@ -93,7 +93,7 @@ services:
2. Пулл изменений в подмодуле и переключение на последний доступный тег:
```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. Запуск контейнеров:
-140
View File
@@ -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
View File
@@ -8,33 +8,66 @@ networks:
default:
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:
outline:
extends:
file: docker-compose.prod.yml
service: outline
image: !reset
pull_policy: !reset
build:
dockerfile: !reset
depends_on:
- outline-postgres
- outline-redis
- outline-oidc
<<: *outline-base
profiles: [prod]
build: .
image: flameshikari/outline-ru:nightly
ports:
- ${PORT:-10240}:${PORT:-10240}
pull_policy: always
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
outline-oidc:
extends:
file: docker-compose.prod.yml
service: outline-oidc
outline-redis:
extends:
file: docker-compose.prod.yml
service: outline-redis
outline-postgres:
extends:
file: docker-compose.prod.yml
service: outline-postgres
outline-services:
container_name: outline-services
image: outline-services
build: services
environment:
ISSUER: http://outline-services:8080
PUBLIC_URL: http://127.0.0.1:${PORT_OIDC:-10241}
volumes:
- outline-postgres:/var/lib/postgresql/data
ports:
- ${PORT_OIDC:-10241}:8080
healthcheck:
test: |
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
+122
View File
@@ -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({
-34
View File
@@ -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/
+17
View File
@@ -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

+40
View File
@@ -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
+13
View File
@@ -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"
}
}
+230
View File
@@ -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> &middot; 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
View File
@@ -1,5 +1,6 @@
{
"New API key": "Новый ключ API",
"Copy": "Скопировать",
"Delete": "Удалить",
"Revoke": "Отозвать",
"Revoke API key": "Отозвать ключ API",
@@ -76,7 +77,6 @@
"Copy public link": "Скопировать публичную ссылку",
"Link copied to clipboard": "Ссылка скопирована в буфер",
"Copy link": "Скопировать ссылку",
"Copy": "Скопировать",
"Duplicate": "Дублировать",
"Duplicate document": "Дублировать документ",
"Copy document": "Скопировать документ",
@@ -173,6 +173,7 @@
"Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent.": "Вы уверены? Удаление шаблона <em>{{ templateName }}</em> необратимо.",
"Move to workspace": "Переместить в рабочее пространство",
"Template moved": "Шаблон перемещён",
"Couldn't move the template, try again?": "Не удалось переместить шаблон. Попробовать снова?",
"Move to collection": "Переместить в коллекцию",
"Move template": "Переместить шаблон",
"Print template": "Распечатать шаблон",
@@ -197,21 +198,21 @@
"People": "Люди",
"Share": "Поделиться",
"Workspace": "Рабочее пространство",
"Recent searches": "Недавние запросы",
"currently editing": "сейчас редактируется",
"currently viewing": "сейчас просматривается",
"previously edited": "ранее отредактировано",
"You": "Вы",
"Avatar of {{ name }}": "Аватар {{ name }}",
"Viewers": "Наблюдатели",
"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": "Расширенные параметры",
"Members": "Участники",
"Public document sharing": "Общий доступ к документу",
"Allow documents within this collection to be shared publicly on the internet.": "Разрешить общий доступ к документам из этой коллекции в Интернете.",
"Commenting": "Комментарий",
"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": "Сохранение",
"Save": "Сохранить",
"Creating": "Создание",
@@ -239,6 +240,7 @@
"Install now": "Установить сейчас",
"Deleted Collection": "Удаленная коллекция",
"Untitled": "Без названия",
"Document options": "Параметры документа",
"Unpin": "Открепить",
"Export started": "Экспорт начат",
"A link to your file will be sent through email soon": "Ссылка на ваш файл скоро будет отправлена по почте",
@@ -263,7 +265,6 @@
"Couldnt move the document, try again?": "Не удалось переместить документ. Попробовать снова?",
"Move to <em>{{ location }}</em>": "Переместить в <em>{{ location }}</em>",
"Couldnt move the template, try again?": "Не удалось переместить шаблон. Попробовать снова?",
"Document options": "Параметры документа",
"New": "Новое",
"Only visible to you": "Видно только вам",
"Draft": "Черновик",
@@ -296,16 +297,15 @@
"Viewed {{ timeAgo }}": "Просмотрено {{ timeAgo }}",
"File type not supported. Please use PNG, JPG, GIF, or WebP.": "Тип файла не поддерживается. Пожалуйста, используйте PNG, JPG, GIF или WebP.",
"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, well try to resize it for you.": "Квадратные изображения с прозрачным фоном подходят лучше всего. Если изображение слишком большое, мы попробуем уменьшить его размер за вас.",
"Upload an image": "Загрузить изображение",
"Click or drag to replace": "Кликните или перетащите, чтобы заменить",
"Drop the image here": "Перетащите изображение сюда",
"Click, drop, or paste an image here": "Кликните, перетащите или вставьте изображение сюда",
"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": "Выберите имя",
"name can only contain lowercase letters, numbers, and underscores.": "имя может состоять только из строчных латинских букв, цифр и подчеркиваний.",
"This emoji will be available as": "Этот эмодзи будет доступен как",
@@ -319,15 +319,6 @@
"our engineers have been notified": "наши инженеры были уведомлены",
"Clear cache + reload": "Очистить кэш и перезагрузить",
"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 as HTML files.": "ZIP-архив, содержащий изображения и документы в формате HTML.",
"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 read": "{{ hours }}ч чтения",
"{{ 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": "Управлять",
"All members": "Все участники",
"Everyone in the workspace": "Все в рабочем пространстве",
@@ -462,6 +444,8 @@
"Switch to light": "Вкл. светлую тему",
"Add": "Добавить",
"Add or invite": "Добавить или пригласить",
"Something went wrong": "Что-то пошло не так",
"Email address": "Адрес почты",
"Viewer": "Наблюдатель",
"Editor": "Редактор",
"Suggestions for invitation": "Предложения для приглашения",
@@ -498,9 +482,9 @@
"Expand sidebar": "Развернуть боковую панель",
"Collapse sidebar": "Свернуть боковую панель",
"Archived collections": "Архивированные коллекции",
"Empty": "Пусто",
"New doc": "Новый документ",
"New nested document": "Новый вложенный документ",
"Empty": "Пусто",
"No collections": "Нет коллекций",
"Collapse": "Свернуть",
"Expand": "Развернуть",
@@ -522,6 +506,7 @@
"{{ documentName }} cannot be moved within {{ parentDocumentName }}": "{{ documentName }} не может быть перемещён внутри {{ parentDocumentName }}",
"You can't reorder documents in an alphabetically sorted collection": "Вы не можете изменить порядок документов в коллекции, отсортированной по алфавиту",
"{{ documentName }} cannot be moved here": "{{ documentName }} нельзя переместить сюда",
"Integrations": "Интеграции",
"Return to App": "На главную",
"Installation": "Установка",
"Unstar document": "Убрать документ из избранного",
@@ -565,12 +550,14 @@
"Height": "Высота",
"Profile picture": "Фото профиля",
"Create a new doc": "Создать новый документ",
"Create a nested doc": "Создать вложенный документ",
"{{ 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 }}», имеющие доступ к этому документу, будут уведомлены",
"Keep as link": "Сохранить как ссылку",
"Mention": "Упоминание",
"Embed": "Вставить",
"Not supported": "Не поддерживается",
"Upload file": "Загрузить файл",
"More options": "Больше параметров",
"Rename": "Переименовать",
"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>? Вы больше не сможете использовать его в своих документах или коллекциях.",
"Edit group": "Редактировать группу",
"Delete group": "Удалить группу",
"Members": "Участники",
"Could not import file": "Не удалось импортировать файл",
"Unsubscribed from document": "Отписаться от документа",
"Unsubscribed from collection": "Отменена подписка на коллекцию",
@@ -693,7 +679,6 @@
"Import": "Импорт",
"Embeds": "Встраивания",
"Configure which embed providers are available in the editor.": "Настройте, какие провайдеры встраиваний доступны в редакторе.",
"Integrations": "Интеграции",
"Install": "Установить",
"Change name": "Изменить имя",
"Change email": "Изменить адрес почты",
@@ -721,6 +706,7 @@
"Revoking": "Отзыв доступа",
"Are you sure you want to revoke access?": "Вы уверены, что хотите отозвать доступ?",
"Delete app": "Удалить приложение",
"Revision options": "Настройка ревизии",
"Share options": "Настройка доступа",
"Headings you add to the document will appear here": "Здесь появятся заголовки, которые вы добавляете в документ",
"Contents": "Содержимое",
@@ -734,8 +720,8 @@
"published": "опубликованный",
"edited": "отредактировано",
"created the collection": "создана коллекция",
"mentioned you in": "упомянул вас в",
"mentioned your group in": "упомянул вашу группу в",
"mentioned you in": "упомянул(а) вас в",
"mentioned your group in": "упомянул(а) вашу группу в",
"left a comment on": "оставил комментарий в",
"resolved a comment on": "отметил комментарий как решённый в",
"reacted {{ emoji }} to your comment on": "оставил реакцию {{ emoji }} на ваш комментарий в",
@@ -836,8 +822,23 @@
"Archived": "Архивировано",
"Save draft": "Сохранить черновик",
"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": "Выделить изменения",
"No history yet": "Истории пока нет",
"Revision deleted": "Ревизия удалена",
"Current version": "Текущая версия",
"{{userName}} edited": "{{userName}} отредактировал",
"{{count}} people_0": "{{count}} человек",
"{{count}} people_1": "{{count}} человека",
"{{count}} people_2": "{{count}} человек",
"Source": "Источник",
"Created": "Создан",
"Imported from {{ source }}": "Импортировано из {{ source }}",
@@ -901,7 +902,6 @@
"Your account has been suspended": "Ваш аккаунт отключён",
"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>) отключил ваш аккаунт. Свяжитесь с ним напрямую, чтобы повторно активировать аккаунт.",
"Something went wrong": "Что-то пошло не так",
"Sorry, an unknown error occurred loading the page. Please try again or contact support if the issue persists.": "Извините, при загрузке страницы произошла неизвестная ошибка. Попробуйте ещё раз или обратитесь в службу поддержки, если проблема не исчезнет.",
"Created by me": "Созданные мной",
"Weird, this shouldn't ever be empty": "Здесь будут появляться недавно обновлённые документы",
@@ -1067,6 +1067,7 @@
"Any time": "За любое время",
"Remove document filter": "Удалить фильтр документа",
"Any status": "Любой статус",
"Recent searches": "Недавние запросы",
"Remove search": "Убрать поиск",
"Relevance": "Релевантность",
"Newest": "Новее",
@@ -1353,7 +1354,6 @@
"Photo": "Фото",
"Choose a photo or image to represent yourself.": "Выберите фотографию или изображение, чтобы представить себя.",
"This could be your real name, or a nickname — however youd like people to refer to you.": "Это может быть ваше настоящее имя или псевдоним — как бы вы хотели, чтобы люди обращались к вам.",
"Email address": "Адрес почты",
"Members and guests": "Участники и гости",
"No one": "Никто",
"Are you sure you want to require invites?": "Вы уверены, что хотите требовать приглашений?",
@@ -1580,6 +1580,9 @@
"Script name": "Имя скрипта",
"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.",
"New webhook": "Новый вебхук",
"Edit webhook": "Редактировать вебхук",
"Delete webhook": "Удалить вебхук",
"Are you sure you want to delete the {{ name }} webhook?": "Вы уверены, что хотите удалить webhook {{ name }}?",
"Webhook updated": "Вебхук обновлен",
"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.": "Подпишитесь на все события, группы или отдельные события. Мы рекомендуем подписаться на минимальное количество событий, которое требуется для функционирования вашего приложения.",
"All events": "Все события",
"All {{ groupName }} events": "Все события {{ groupName }}",
"Delete webhook": "Удалить вебхук",
"Subscribed events": "События, на которые оформлена подписка",
"Edit webhook": "Редактировать вебхук",
"Webhook created": "Вебхук создан",
"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-полезной нагрузкой практически в реальном времени.",
"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}} легко интегрироваться с тысячами других бизнес-инструментов. Автоматизируйте рабочие процессы, синхронизируйте данные и многое другое.",
"Never logged in": "Никогда не входил",
"Online now": "Сейчас в сети",
@@ -1614,7 +1612,175 @@
"Open": "Открыть",
"Loading": "Загрузка",
"Error loading data": "Не удалось загрузить данные",
"Couldn't move the template, try again?": "Не удалось переместить шаблон. Попробовать снова?",
"Create a nested doc": "Создать вложенный документ",
"Upload file": "Загрузить файл"
"API key copied": "Ключ API скопирован",
"Search results": "Результаты поиска",
"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": "Ваш запрос на изменение адреса почты",
"Heres 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": "Код подтверждения входа",
"Heres 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": "руководстве"
}