Compare commits

...

10 Commits

Author SHA1 Message Date
Evgeny e026250893 1.8.1 (#34)
* bump version to 1.8.1

* update translation
2026-06-10 06:55:14 +05:00
Evgeny d2be66831d 1.8.0 (#33)
* bump version to 1.8.0

* update Dockerfile

* update translation
2026-06-02 04:59:47 +05:00
flameshikari 49472ac801 update translations
Build / Build [amd64] (push) Has been cancelled
Build / Build [arm64] (push) Has been cancelled
Build / Publish (push) Has been cancelled
2026-05-04 10:33:19 +05:00
flameshikari a81bd3bd17 bump version to 1.7.1 2026-05-04 10:22:47 +05:00
flameshikari f22d5952bb fix workflow permission
Build / Build [amd64] (push) Has been cancelled
Build / Build [arm64] (push) Has been cancelled
Build / Publish (push) Has been cancelled
2026-05-01 09:31:35 +05:00
flameshikari 02bdd461b4 fix workflow token 2026-05-01 09:28:49 +05:00
Evgeny d0abf84aa8 1.7.0 (#32)
* bump version to 1.7.0

* update HMR mode; make a single dev container for every service; etc

* update translations
2026-05-01 09:14:01 +05:00
Aleksandr Posazhennikov b3bda3622c upd: ru.json (#31)
Build / Build [amd64] (push) Has been cancelled
Build / Build [arm64] (push) Has been cancelled
Build / Publish (push) Has been cancelled
* upd: ru.json

Заменил переводы некоторых фраз на более подходящие по смыслу и синтаксису

* return indents in ru.json

---------

Co-authored-by: flameshikari <mail@hexed.pw>
2026-04-14 00:05:10 +05:00
flameshikari 856cf4b0c9 update translations
Build / Build [amd64] (push) Has been cancelled
Build / Build [arm64] (push) Has been cancelled
Build / Publish (push) Has been cancelled
2026-03-21 00:11:08 +05:00
flameshikari 0bc1c14a9c bump version to 1.6.1 2026-03-21 00:10:47 +05:00
17 changed files with 1134 additions and 594 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
+15 -17
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/*
@@ -105,36 +103,36 @@ jobs:
runs-on: ubuntu-24.04
needs: build
permissions:
contents: read
contents: write
packages: write
env:
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,10 +151,10 @@ 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 }}
tag_name: ${{ env.version }}
body: "[Изменения в ${{ env.version }}](https://github.com/outline/outline/releases/tag/v${{ env.version }})"
token: ${{ secrets.TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}
+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.16.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.16.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.5.0
# image: ghcr.io/flameshikari/outline-ru:1.5.0
image: flameshikari/outline-ru:1.8.1
# image: ghcr.io/flameshikari/outline-ru:1.8.1
env_file: ./docker.env
expose:
- 3000
@@ -93,7 +93,7 @@ services:
2. Пулл изменений в подмодуле и переключение на последний доступный тег:
```sh
git submodule foreach 'git pull --rebase --tags && git checkout v1.5.0'
git submodule foreach 'git pull --rebase --tags && git checkout v1.8.1'
```
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: a860cfc9ec...c2edd41e87
+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(',')})`);
});
+572 -308
View File
File diff suppressed because it is too large Load Diff