feat: in-app Chat (Matrix JWT SSO) + Drive (embedded oCIS)
- api: matrix-sso module (tuwunel jwt -> login/get_token), POST /matrix/session, GET /matrix/identity, drive controller - web: /chat full-height Element view, /files Library+Drive tabs, embedded-app frame component - edge: /hub-sso.html handshake route (origin-pinned CSP), X-Frame-Options strip on files - oCIS: csp.yaml frame-ancestors app.occupymedia.cc - server config still required: tuwunel [global.jwt], MATRIX_SSO_JWT_SECRET, oCIS restart
This commit is contained in:
parent
a2aa70c961
commit
fe5f2403d6
22 changed files with 1057 additions and 8 deletions
|
|
@ -37,9 +37,10 @@ import { DirectorModule } from "./director/director.module.js";
|
||||||
import { AnalyticsController } from "./analytics/analytics.controller";
|
import { AnalyticsController } from "./analytics/analytics.controller";
|
||||||
import { SnapshotService } from "./analytics/snapshot.service";
|
import { SnapshotService } from "./analytics/snapshot.service";
|
||||||
import { CollectivesModule } from "./collectives/collectives.module.js";
|
import { CollectivesModule } from "./collectives/collectives.module.js";
|
||||||
|
import { MatrixSsoModule } from "./matrix-sso/matrix-sso.module.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule, CollectivesModule],
|
imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule, CollectivesModule, MatrixSsoModule],
|
||||||
controllers: [
|
controllers: [
|
||||||
DemoController,
|
DemoController,
|
||||||
PublishingController,
|
PublishingController,
|
||||||
|
|
|
||||||
55
apps/api/src/group-files/drive.controller.ts
Normal file
55
apps/api/src/group-files/drive.controller.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { Controller, Get, UseGuards } from "@nestjs/common";
|
||||||
|
import { RbacGuard, RequirePermission } from "../rbac/rbac.guard.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the in-app Drive view reaches the oCIS web UI.
|
||||||
|
*
|
||||||
|
* interactive oCIS web uses its own identity provider. The user signs in
|
||||||
|
* ONCE inside the embedded frame; oCIS web keeps its OIDC
|
||||||
|
* tokens in the files origin's storage, which the Hub frame
|
||||||
|
* shares (app.* and files.* are the same site), so later
|
||||||
|
* visits open signed in.
|
||||||
|
* oidc oCIS and the Hub trust the same external OIDC issuer; the
|
||||||
|
* embedded oCIS web completes its authorization-code flow
|
||||||
|
* silently against the issuer's existing session.
|
||||||
|
*/
|
||||||
|
export type DriveAuthMode = "interactive" | "oidc";
|
||||||
|
|
||||||
|
export interface DriveSession {
|
||||||
|
/** Origin of the oCIS web UI, or null when the drive is not deployed. */
|
||||||
|
driveUrl: string | null;
|
||||||
|
authMode: DriveAuthMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ENV CONTRACT (read per call):
|
||||||
|
*
|
||||||
|
* FILES_DRIVE_URL oCIS web origin; default FILES_PUBLIC_BASE (the same
|
||||||
|
* host the library links already point at).
|
||||||
|
* FILES_DRIVE_AUTH "oidc" once oCIS and the Hub share an issuer; anything
|
||||||
|
* else (or unset) is "interactive".
|
||||||
|
*/
|
||||||
|
export function driveSessionFromEnv(env: Readonly<Record<string, string | undefined>> = process.env): DriveSession {
|
||||||
|
const raw = env.FILES_DRIVE_URL?.trim() || env.FILES_PUBLIC_BASE?.trim() || "";
|
||||||
|
let driveUrl: string | null = null;
|
||||||
|
if (raw !== "") {
|
||||||
|
try {
|
||||||
|
const url = new URL(raw);
|
||||||
|
if (url.protocol === "https:" || url.protocol === "http:") driveUrl = url.origin;
|
||||||
|
} catch {
|
||||||
|
driveUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { driveUrl, authMode: env.FILES_DRIVE_AUTH?.trim() === "oidc" ? "oidc" : "interactive" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /drive/session — runtime drive location for the embedded Drive view. */
|
||||||
|
@Controller("drive")
|
||||||
|
@UseGuards(RbacGuard)
|
||||||
|
export class DriveController {
|
||||||
|
@Get("session")
|
||||||
|
@RequirePermission("media.view")
|
||||||
|
session(): DriveSession {
|
||||||
|
return driveSessionFromEnv();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { GroupFilesController, PublicFileShareController } from "./group-files.controller.js";
|
import { GroupFilesController, PublicFileShareController } from "./group-files.controller.js";
|
||||||
import { GroupFilesService } from "./group-files.service.js";
|
import { GroupFilesService } from "./group-files.service.js";
|
||||||
|
import { DriveController } from "./drive.controller.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Group files module — the metadata index + share/link lifecycle over
|
* Group files module — the metadata index + share/link lifecycle over
|
||||||
|
|
@ -20,7 +21,7 @@ import { GroupFilesService } from "./group-files.service.js";
|
||||||
* deployment is absent instead of breaking startup.
|
* deployment is absent instead of breaking startup.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [GroupFilesController, PublicFileShareController],
|
controllers: [GroupFilesController, PublicFileShareController, DriveController],
|
||||||
providers: [GroupFilesService],
|
providers: [GroupFilesService],
|
||||||
exports: [GroupFilesService],
|
exports: [GroupFilesService],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
128
apps/api/src/matrix-sso/matrix-sso.config.ts
Normal file
128
apps/api/src/matrix-sso/matrix-sso.config.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { createHmac } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hub → Matrix single sign-on configuration.
|
||||||
|
*
|
||||||
|
* The Hub never holds a Matrix password for its users. tuwunel (1.9.4) accepts
|
||||||
|
* `org.matrix.login.jwt` logins signed with a shared HMAC secret
|
||||||
|
* (`[global.jwt]` in tuwunel.toml) and, with `register_user = true`, creates
|
||||||
|
* the account on first login. The Hub signs a 60-second JWT for the CURRENT
|
||||||
|
* session's user, trades it for a broker access token, asks
|
||||||
|
* POST /_matrix/client/v1/login/get_token for a short-lived m.login.token
|
||||||
|
* (UIA satisfied by the same JWT), and logs the broker device out again. The
|
||||||
|
* m.login.token is what Element consumes.
|
||||||
|
*
|
||||||
|
* ENV CONTRACT (read per call, like ocisConfigFromEnv — tests reconfigure
|
||||||
|
* without a rebuild; nothing is read at import time):
|
||||||
|
*
|
||||||
|
* MATRIX_SSO_JWT_SECRET REQUIRED to enable. Same value as
|
||||||
|
* `[global.jwt] key` in tuwunel.toml (format HMAC).
|
||||||
|
* MATRIX_SSO_HOMESERVER_URL client-server base, default
|
||||||
|
* ARCHIVE_PROVISIONER_HOMESERVER_URL.
|
||||||
|
* MATRIX_SSO_SERVER_NAME Matrix server_name for MXIDs, default the
|
||||||
|
* homeserver URL's hostname.
|
||||||
|
* MATRIX_SSO_ELEMENT_URL Element Web origin, default the homeserver
|
||||||
|
* URL's origin (chat.occupymedia.cc serves both).
|
||||||
|
* MATRIX_SSO_JWT_ISSUER `iss` claim, default "tmbh-hub".
|
||||||
|
* MATRIX_SSO_JWT_AUDIENCE `aud` claim, default the server name.
|
||||||
|
* MATRIX_SSO_LOCALPART_PREFIX prefix for Hub-derived localparts, default
|
||||||
|
* "hub_". The literal "none" disables it — only
|
||||||
|
* do that when every pre-existing Matrix
|
||||||
|
* account belongs to the Hub user of the same
|
||||||
|
* name (otherwise a Hub sign-up named after an
|
||||||
|
* existing chat user would sign in AS them).
|
||||||
|
*/
|
||||||
|
export interface MatrixSsoConfig {
|
||||||
|
homeserverUrl: URL;
|
||||||
|
serverName: string;
|
||||||
|
elementUrl: URL;
|
||||||
|
jwtSecret: string;
|
||||||
|
issuer: string;
|
||||||
|
audience: string;
|
||||||
|
localpartPrefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MatrixSsoConfigError extends Error {}
|
||||||
|
|
||||||
|
const DEFAULT_PREFIX = "hub_";
|
||||||
|
const DEFAULT_ISSUER = "tmbh-hub";
|
||||||
|
|
||||||
|
function httpUrl(name: string, raw: string): URL {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
throw new MatrixSsoConfigError(`${name} must be an absolute URL`);
|
||||||
|
}
|
||||||
|
if (url.protocol !== "https:" && url.protocol !== "http:") throw new MatrixSsoConfigError(`${name} must be http(s)`);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimmed(env: Readonly<Record<string, string | undefined>>, name: string): string {
|
||||||
|
return env[name]?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** null = SSO not configured (routes answer 503); throws on malformed config. */
|
||||||
|
export function loadMatrixSsoConfig(env: Readonly<Record<string, string | undefined>> = process.env): MatrixSsoConfig | null {
|
||||||
|
const jwtSecret = trimmed(env, "MATRIX_SSO_JWT_SECRET");
|
||||||
|
if (jwtSecret === "") return null;
|
||||||
|
const rawHomeserver = trimmed(env, "MATRIX_SSO_HOMESERVER_URL") || trimmed(env, "ARCHIVE_PROVISIONER_HOMESERVER_URL");
|
||||||
|
if (rawHomeserver === "") {
|
||||||
|
throw new MatrixSsoConfigError("MATRIX_SSO_HOMESERVER_URL (or ARCHIVE_PROVISIONER_HOMESERVER_URL) is required");
|
||||||
|
}
|
||||||
|
const homeserverUrl = httpUrl("MATRIX_SSO_HOMESERVER_URL", rawHomeserver);
|
||||||
|
const serverName = trimmed(env, "MATRIX_SSO_SERVER_NAME") || homeserverUrl.hostname;
|
||||||
|
const rawElement = trimmed(env, "MATRIX_SSO_ELEMENT_URL");
|
||||||
|
const elementUrl = rawElement === "" ? new URL(homeserverUrl.origin) : httpUrl("MATRIX_SSO_ELEMENT_URL", rawElement);
|
||||||
|
const rawPrefix = trimmed(env, "MATRIX_SSO_LOCALPART_PREFIX");
|
||||||
|
const localpartPrefix = rawPrefix === "" ? DEFAULT_PREFIX : rawPrefix === "none" ? "" : rawPrefix;
|
||||||
|
if (!LOCALPART.test(`${localpartPrefix}x`)) {
|
||||||
|
throw new MatrixSsoConfigError("MATRIX_SSO_LOCALPART_PREFIX may only contain a-z 0-9 . _ = - /");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
homeserverUrl,
|
||||||
|
serverName,
|
||||||
|
elementUrl,
|
||||||
|
jwtSecret,
|
||||||
|
issuer: trimmed(env, "MATRIX_SSO_JWT_ISSUER") || DEFAULT_ISSUER,
|
||||||
|
audience: trimmed(env, "MATRIX_SSO_JWT_AUDIENCE") || serverName,
|
||||||
|
localpartPrefix,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matrix user-ID grammar for localparts (spec "User Identifiers"). */
|
||||||
|
const LOCALPART = /^[a-z0-9._=\-/]+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hub username → Matrix user ID. Hub usernames are already [a-z0-9_.-]
|
||||||
|
* (016_username_auth.sql), a subset of the localpart grammar; anything else is
|
||||||
|
* refused rather than rewritten so two Hub users can never map to one MXID.
|
||||||
|
*/
|
||||||
|
export function matrixUserIdFor(config: MatrixSsoConfig, username: string): { localpart: string; userId: string } | null {
|
||||||
|
const localpart = `${config.localpartPrefix}${username.toLowerCase()}`;
|
||||||
|
if (username === "" || !LOCALPART.test(localpart) || localpart !== `${config.localpartPrefix}${username}`) return null;
|
||||||
|
return { localpart, userId: `@${localpart}:${config.serverName}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lifetime of the JWT the Hub hands tuwunel; it is spent within one request. */
|
||||||
|
export const SSO_JWT_TTL_SECONDS = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HS256 JWT for tuwunel's `[global.jwt]` validator: `sub` is the localpart
|
||||||
|
* (tuwunel lowercases and qualifies it with its own server_name), and
|
||||||
|
* iss/aud/exp/nbf are always present so the homeserver can require them.
|
||||||
|
*/
|
||||||
|
export function signSsoJwt(config: MatrixSsoConfig, localpart: string, nowSeconds = Math.floor(Date.now() / 1000)): string {
|
||||||
|
const encode = (value: Record<string, unknown>): string => Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
||||||
|
const header = encode({ alg: "HS256", typ: "JWT" });
|
||||||
|
const payload = encode({
|
||||||
|
sub: localpart,
|
||||||
|
iss: config.issuer,
|
||||||
|
aud: config.audience,
|
||||||
|
iat: nowSeconds,
|
||||||
|
nbf: nowSeconds - 5,
|
||||||
|
exp: nowSeconds + SSO_JWT_TTL_SECONDS,
|
||||||
|
});
|
||||||
|
const signature = createHmac("sha256", config.jwtSecret).update(`${header}.${payload}`).digest("base64url");
|
||||||
|
return `${header}.${payload}.${signature}`;
|
||||||
|
}
|
||||||
35
apps/api/src/matrix-sso/matrix-sso.controller.ts
Normal file
35
apps/api/src/matrix-sso/matrix-sso.controller.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Controller, Get, HttpCode, Inject, Post, Req, UseGuards } from "@nestjs/common";
|
||||||
|
import type { Request } from "express";
|
||||||
|
import { RbacGuard, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
|
||||||
|
import { requireSession } from "../http/validation.js";
|
||||||
|
import { MatrixSsoService, type MatrixIdentity, type MatrixSession } from "./matrix-sso.service.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hub → Matrix single sign-on for the in-app Chat view.
|
||||||
|
*
|
||||||
|
* GET /matrix/identity the caller's MXID + Element/homeserver origins (no secrets)
|
||||||
|
* POST /matrix/session mint a single-use m.login.token for the caller
|
||||||
|
*
|
||||||
|
* Any authenticated member may use both: chat is not a privileged surface, and
|
||||||
|
* the token is only ever minted for the session's OWN user. POST because each
|
||||||
|
* call creates a homeserver-side credential; responses are never cached.
|
||||||
|
*/
|
||||||
|
@Controller("matrix")
|
||||||
|
@UseGuards(RbacGuard)
|
||||||
|
export class MatrixSsoController {
|
||||||
|
constructor(@Inject(MatrixSsoService) private readonly sso: MatrixSsoService) {}
|
||||||
|
|
||||||
|
@Get("identity")
|
||||||
|
async identity(@Req() request: Request & AuthenticatedRequest): Promise<MatrixIdentity> {
|
||||||
|
const session = requireSession(request);
|
||||||
|
return this.sso.identity(session.workspaceId, session.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("session")
|
||||||
|
@HttpCode(200)
|
||||||
|
async session(@Req() request: Request & AuthenticatedRequest): Promise<MatrixSession> {
|
||||||
|
const session = requireSession(request);
|
||||||
|
request.res?.setHeader("Cache-Control", "no-store");
|
||||||
|
return this.sso.mintSession(session.workspaceId, session.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/matrix-sso/matrix-sso.module.ts
Normal file
14
apps/api/src/matrix-sso/matrix-sso.module.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { MatrixSsoController } from "./matrix-sso.controller.js";
|
||||||
|
import { MatrixSsoService } from "./matrix-sso.service.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matrix SSO module — signs Hub users into Element without a Matrix password.
|
||||||
|
* Env is read per request (see matrix-sso.config.ts), so a deployment without
|
||||||
|
* MATRIX_SSO_JWT_SECRET boots fine and the mint route answers 503.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
controllers: [MatrixSsoController],
|
||||||
|
providers: [MatrixSsoService],
|
||||||
|
})
|
||||||
|
export class MatrixSsoModule {}
|
||||||
168
apps/api/src/matrix-sso/matrix-sso.service.ts
Normal file
168
apps/api/src/matrix-sso/matrix-sso.service.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import { ConflictException, Inject, Injectable, Logger, Optional, ServiceUnavailableException } from "@nestjs/common";
|
||||||
|
import { withTenant } from "../db/client.js";
|
||||||
|
import { loadMatrixSsoConfig, matrixUserIdFor, signSsoJwt, type MatrixSsoConfig } from "./matrix-sso.config.js";
|
||||||
|
|
||||||
|
/** What the Hub tells the web client about the caller's chat identity (no secrets). */
|
||||||
|
export interface MatrixIdentity {
|
||||||
|
configured: boolean;
|
||||||
|
homeserverUrl: string | null;
|
||||||
|
elementUrl: string | null;
|
||||||
|
/** Full MXID the Hub signs this user in as; null when SSO is not configured. */
|
||||||
|
userId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A freshly minted single-use m.login.token for Element's `?loginToken=` flow. */
|
||||||
|
export interface MatrixSession {
|
||||||
|
homeserverUrl: string;
|
||||||
|
elementUrl: string;
|
||||||
|
userId: string;
|
||||||
|
loginToken: string;
|
||||||
|
expiresInMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal fetch surface so tests stub the homeserver without HTTP. */
|
||||||
|
export type MatrixFetch = (url: string, init: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
export const MATRIX_SSO_FETCH: unique symbol = Symbol("MATRIX_SSO_FETCH");
|
||||||
|
|
||||||
|
/** Stable broker device: a failed logout leaves at most ONE stray device per user. */
|
||||||
|
const BROKER_DEVICE_ID = "HUB_SSO_BROKER";
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Homeserver error text WITHOUT echoing request bodies (they carry the JWT). */
|
||||||
|
async function describe(res: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
const body = record(await res.json());
|
||||||
|
const errcode = typeof body.errcode === "string" ? body.errcode : "unknown";
|
||||||
|
return `HTTP ${res.status} ${errcode}`;
|
||||||
|
} catch {
|
||||||
|
return `HTTP ${res.status}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hub → Matrix SSO. Every mint is for the session's own user: the user id is
|
||||||
|
* derived server-side from `users.username`, never taken from the request.
|
||||||
|
* Nothing minted here is logged or persisted; the broker access token lives
|
||||||
|
* for the duration of one request and is logged out before returning.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MatrixSsoService {
|
||||||
|
private readonly logger = new Logger("MatrixSso");
|
||||||
|
private readonly fetcher: MatrixFetch;
|
||||||
|
|
||||||
|
constructor(@Optional() @Inject(MATRIX_SSO_FETCH) fetcher?: MatrixFetch) {
|
||||||
|
this.fetcher = fetcher ?? ((url, init) => fetch(url, init));
|
||||||
|
}
|
||||||
|
|
||||||
|
async identity(workspaceId: string, userId: string): Promise<MatrixIdentity> {
|
||||||
|
const config = loadMatrixSsoConfig();
|
||||||
|
if (config === null) return { configured: false, homeserverUrl: null, elementUrl: null, userId: null };
|
||||||
|
const mapped = matrixUserIdFor(config, await this.usernameFor(workspaceId, userId));
|
||||||
|
return {
|
||||||
|
configured: true,
|
||||||
|
homeserverUrl: config.homeserverUrl.origin,
|
||||||
|
elementUrl: config.elementUrl.origin,
|
||||||
|
userId: mapped?.userId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async mintSession(workspaceId: string, userId: string): Promise<MatrixSession> {
|
||||||
|
const config = loadMatrixSsoConfig();
|
||||||
|
if (config === null) {
|
||||||
|
throw new ServiceUnavailableException("chat sign-in is not configured: set MATRIX_SSO_JWT_SECRET and MATRIX_SSO_HOMESERVER_URL");
|
||||||
|
}
|
||||||
|
const mapped = matrixUserIdFor(config, await this.usernameFor(workspaceId, userId));
|
||||||
|
if (mapped === null) throw new ConflictException("this username cannot be used as a Matrix user id");
|
||||||
|
return this.mintFor(config, mapped.localpart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JWT login → get_token (UIA via the same JWT) → logout. Exposed for tests. */
|
||||||
|
async mintFor(config: MatrixSsoConfig, localpart: string): Promise<MatrixSession> {
|
||||||
|
const jwt = signSsoJwt(config, localpart);
|
||||||
|
const login = await this.call(config, "/_matrix/client/v3/login", null, {
|
||||||
|
type: "org.matrix.login.jwt",
|
||||||
|
token: jwt,
|
||||||
|
device_id: BROKER_DEVICE_ID,
|
||||||
|
initial_device_display_name: "Hub sign-in broker",
|
||||||
|
}, "JWT login");
|
||||||
|
const accessToken = login.access_token;
|
||||||
|
const matrixUserId = login.user_id;
|
||||||
|
if (typeof accessToken !== "string" || typeof matrixUserId !== "string") {
|
||||||
|
throw new ServiceUnavailableException("homeserver login response missing access_token/user_id");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const minted = await this.call(config, "/_matrix/client/v1/login/get_token", accessToken, {
|
||||||
|
auth: { type: "org.matrix.login.jwt", token: jwt },
|
||||||
|
}, "get_token");
|
||||||
|
const loginToken = minted.login_token;
|
||||||
|
if (typeof loginToken !== "string" || loginToken === "") {
|
||||||
|
throw new ServiceUnavailableException("homeserver get_token response missing login_token");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
homeserverUrl: config.homeserverUrl.origin,
|
||||||
|
elementUrl: config.elementUrl.origin,
|
||||||
|
userId: matrixUserId,
|
||||||
|
loginToken,
|
||||||
|
expiresInMs: typeof minted.expires_in_ms === "number" ? minted.expires_in_ms : 120_000,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await this.logout(config, accessToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async call(
|
||||||
|
config: MatrixSsoConfig,
|
||||||
|
path: string,
|
||||||
|
accessToken: string | null,
|
||||||
|
json: Record<string, unknown>,
|
||||||
|
step: string,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const headers: Record<string, string> = { accept: "application/json", "content-type": "application/json" };
|
||||||
|
if (accessToken !== null) headers.authorization = `Bearer ${accessToken}`;
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await this.fetcher(new URL(path, config.homeserverUrl).toString(), {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(json),
|
||||||
|
redirect: "error",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`${step} unreachable: ${err instanceof Error ? err.name : "error"}`);
|
||||||
|
throw new ServiceUnavailableException(`chat homeserver unreachable (${step})`);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await describe(res);
|
||||||
|
this.logger.warn(`${step} refused: ${detail}`);
|
||||||
|
throw new ServiceUnavailableException(`chat homeserver refused ${step} (${detail})`);
|
||||||
|
}
|
||||||
|
return record(await res.json().catch(() => null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort: a stray broker device is harmless (fixed id, reused next mint). */
|
||||||
|
private async logout(config: MatrixSsoConfig, accessToken: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.fetcher(new URL("/_matrix/client/v3/logout", config.homeserverUrl).toString(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { accept: "application/json", "content-type": "application/json", authorization: `Bearer ${accessToken}` },
|
||||||
|
body: "{}",
|
||||||
|
redirect: "error",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
this.logger.warn("broker logout failed; device HUB_SSO_BROKER will be reused on the next mint");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async usernameFor(workspaceId: string, userId: string): Promise<string> {
|
||||||
|
const row = await withTenant(workspaceId, (client) =>
|
||||||
|
client.query<{ username: string }>("SELECT username FROM users WHERE id = $1", [userId]),
|
||||||
|
);
|
||||||
|
const username = row.rows[0]?.username;
|
||||||
|
if (username === undefined) throw new ConflictException("no username on this account");
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
}
|
||||||
181
apps/api/src/matrix-sso/matrix-sso.test.ts
Normal file
181
apps/api/src/matrix-sso/matrix-sso.test.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import { createHmac } from "node:crypto";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ServiceUnavailableException } from "@nestjs/common";
|
||||||
|
import { loadMatrixSsoConfig, matrixUserIdFor, signSsoJwt, MatrixSsoConfigError, SSO_JWT_TTL_SECONDS, type MatrixSsoConfig } from "./matrix-sso.config.js";
|
||||||
|
import { MatrixSsoService, type MatrixFetch } from "./matrix-sso.service.js";
|
||||||
|
import { driveSessionFromEnv } from "../group-files/drive.controller.js";
|
||||||
|
|
||||||
|
const BASE_ENV = {
|
||||||
|
MATRIX_SSO_JWT_SECRET: "test-shared-secret",
|
||||||
|
MATRIX_SSO_HOMESERVER_URL: "https://chat.example.org",
|
||||||
|
};
|
||||||
|
|
||||||
|
function config(env: Record<string, string> = BASE_ENV): MatrixSsoConfig {
|
||||||
|
const loaded = loadMatrixSsoConfig(env);
|
||||||
|
if (loaded === null) throw new Error("expected config");
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodePart(part: string): Record<string, unknown> {
|
||||||
|
return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Recorded {
|
||||||
|
url: string;
|
||||||
|
authorization: string | null;
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fake tuwunel: answers login / get_token / logout, records every call. */
|
||||||
|
function fakeHomeserver(overrides: { getTokenStatus?: number } = {}): { fetcher: MatrixFetch; calls: Recorded[] } {
|
||||||
|
const calls: Recorded[] = [];
|
||||||
|
const fetcher: MatrixFetch = async (url, init) => {
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
calls.push({
|
||||||
|
url,
|
||||||
|
authorization: headers.get("authorization"),
|
||||||
|
body: typeof init.body === "string" ? (JSON.parse(init.body) as Record<string, unknown>) : {},
|
||||||
|
});
|
||||||
|
const path = new URL(url).pathname;
|
||||||
|
if (path === "/_matrix/client/v3/login") {
|
||||||
|
return Response.json({ access_token: "broker-access", user_id: "@hub_ada:chat.example.org", device_id: "HUB_SSO_BROKER" });
|
||||||
|
}
|
||||||
|
if (path === "/_matrix/client/v1/login/get_token") {
|
||||||
|
if (overrides.getTokenStatus !== undefined) {
|
||||||
|
return Response.json({ errcode: "M_FORBIDDEN", error: "Login via an existing session is not enabled" }, { status: overrides.getTokenStatus });
|
||||||
|
}
|
||||||
|
return Response.json({ login_token: "single-use-login-token", expires_in_ms: 120000 });
|
||||||
|
}
|
||||||
|
if (path === "/_matrix/client/v3/logout") return Response.json({});
|
||||||
|
return Response.json({ errcode: "M_UNRECOGNIZED" }, { status: 404 });
|
||||||
|
};
|
||||||
|
return { fetcher, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("loadMatrixSsoConfig", () => {
|
||||||
|
it("is disabled without a JWT secret", () => {
|
||||||
|
expect(loadMatrixSsoConfig({ MATRIX_SSO_HOMESERVER_URL: "https://chat.example.org" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives server name, Element origin, audience and the safe prefix from the homeserver URL", () => {
|
||||||
|
const loaded = config();
|
||||||
|
expect(loaded.serverName).toBe("chat.example.org");
|
||||||
|
expect(loaded.elementUrl.origin).toBe("https://chat.example.org");
|
||||||
|
expect(loaded.audience).toBe("chat.example.org");
|
||||||
|
expect(loaded.issuer).toBe("tmbh-hub");
|
||||||
|
expect(loaded.localpartPrefix).toBe("hub_");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the provisioner homeserver URL", () => {
|
||||||
|
const loaded = config({ MATRIX_SSO_JWT_SECRET: "s", ARCHIVE_PROVISIONER_HOMESERVER_URL: "https://hs.example.org" });
|
||||||
|
expect(loaded.homeserverUrl.origin).toBe("https://hs.example.org");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only drops the prefix on the explicit 'none' sentinel (an empty compose passthrough keeps it)", () => {
|
||||||
|
expect(config({ ...BASE_ENV, MATRIX_SSO_LOCALPART_PREFIX: "" }).localpartPrefix).toBe("hub_");
|
||||||
|
expect(config({ ...BASE_ENV, MATRIX_SSO_LOCALPART_PREFIX: "none" }).localpartPrefix).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed values", () => {
|
||||||
|
expect(() => loadMatrixSsoConfig({ MATRIX_SSO_JWT_SECRET: "s" })).toThrow(MatrixSsoConfigError);
|
||||||
|
expect(() => loadMatrixSsoConfig({ ...BASE_ENV, MATRIX_SSO_HOMESERVER_URL: "ftp://x" })).toThrow(MatrixSsoConfigError);
|
||||||
|
expect(() => loadMatrixSsoConfig({ ...BASE_ENV, MATRIX_SSO_LOCALPART_PREFIX: "Hub:" })).toThrow(MatrixSsoConfigError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("matrixUserIdFor", () => {
|
||||||
|
it("prefixes and qualifies Hub usernames", () => {
|
||||||
|
expect(matrixUserIdFor(config(), "ada.l")).toEqual({ localpart: "hub_ada.l", userId: "@hub_ada.l:chat.example.org" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses usernames tuwunel would rewrite (case folding would merge accounts)", () => {
|
||||||
|
expect(matrixUserIdFor(config(), "Ada")).toBeNull();
|
||||||
|
expect(matrixUserIdFor(config(), "ada lovelace")).toBeNull();
|
||||||
|
expect(matrixUserIdFor(config(), "")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("signSsoJwt", () => {
|
||||||
|
it("produces an HS256 token tuwunel's [global.jwt] validator accepts", () => {
|
||||||
|
const loaded = config();
|
||||||
|
const token = signSsoJwt(loaded, "hub_ada", 1_000_000);
|
||||||
|
const [header, payload, signature] = token.split(".");
|
||||||
|
expect(decodePart(header!)).toEqual({ alg: "HS256", typ: "JWT" });
|
||||||
|
expect(decodePart(payload!)).toEqual({
|
||||||
|
sub: "hub_ada",
|
||||||
|
iss: "tmbh-hub",
|
||||||
|
aud: "chat.example.org",
|
||||||
|
iat: 1_000_000,
|
||||||
|
nbf: 1_000_000 - 5,
|
||||||
|
exp: 1_000_000 + SSO_JWT_TTL_SECONDS,
|
||||||
|
});
|
||||||
|
const expected = createHmac("sha256", "test-shared-secret").update(`${header}.${payload}`).digest("base64url");
|
||||||
|
expect(signature).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MatrixSsoService.mintFor", () => {
|
||||||
|
it("JWT login → get_token with JWT UIA → logout, returning the login token", async () => {
|
||||||
|
const { fetcher, calls } = fakeHomeserver();
|
||||||
|
const session = await new MatrixSsoService(fetcher).mintFor(config(), "hub_ada");
|
||||||
|
|
||||||
|
expect(session).toEqual({
|
||||||
|
homeserverUrl: "https://chat.example.org",
|
||||||
|
elementUrl: "https://chat.example.org",
|
||||||
|
userId: "@hub_ada:chat.example.org",
|
||||||
|
loginToken: "single-use-login-token",
|
||||||
|
expiresInMs: 120000,
|
||||||
|
});
|
||||||
|
expect(calls.map((call) => new URL(call.url).pathname)).toEqual([
|
||||||
|
"/_matrix/client/v3/login",
|
||||||
|
"/_matrix/client/v1/login/get_token",
|
||||||
|
"/_matrix/client/v3/logout",
|
||||||
|
]);
|
||||||
|
const [login, getToken, logout] = calls;
|
||||||
|
expect(login!.authorization).toBeNull();
|
||||||
|
expect(login!.body.type).toBe("org.matrix.login.jwt");
|
||||||
|
expect(login!.body.device_id).toBe("HUB_SSO_BROKER");
|
||||||
|
expect(getToken!.authorization).toBe("Bearer broker-access");
|
||||||
|
expect(getToken!.body.auth).toEqual({ type: "org.matrix.login.jwt", token: login!.body.token });
|
||||||
|
expect(logout!.authorization).toBe("Bearer broker-access");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a refused get_token to 503, still logs the broker out, and never echoes the JWT", async () => {
|
||||||
|
const { fetcher, calls } = fakeHomeserver({ getTokenStatus: 403 });
|
||||||
|
const error = await new MatrixSsoService(fetcher).mintFor(config(), "hub_ada").catch((err: unknown) => err);
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(ServiceUnavailableException);
|
||||||
|
const message = error instanceof Error ? error.message : "";
|
||||||
|
expect(message).toContain("M_FORBIDDEN");
|
||||||
|
expect(message).not.toContain(String(calls[0]!.body.token));
|
||||||
|
expect(new URL(calls.at(-1)!.url).pathname).toBe("/_matrix/client/v3/logout");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps an unreachable homeserver to 503", async () => {
|
||||||
|
const fetcher: MatrixFetch = async () => {
|
||||||
|
throw new TypeError("fetch failed");
|
||||||
|
};
|
||||||
|
await expect(new MatrixSsoService(fetcher).mintFor(config(), "hub_ada")).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("driveSessionFromEnv", () => {
|
||||||
|
it("defaults to the library's public base and interactive sign-in", () => {
|
||||||
|
expect(driveSessionFromEnv({ FILES_PUBLIC_BASE: "https://files.example.org/" })).toEqual({
|
||||||
|
driveUrl: "https://files.example.org",
|
||||||
|
authMode: "interactive",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers FILES_DRIVE_URL and honours FILES_DRIVE_AUTH=oidc", () => {
|
||||||
|
expect(driveSessionFromEnv({ FILES_DRIVE_URL: "https://drive.example.org", FILES_PUBLIC_BASE: "https://x", FILES_DRIVE_AUTH: "oidc" })).toEqual({
|
||||||
|
driveUrl: "https://drive.example.org",
|
||||||
|
authMode: "oidc",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports no drive when nothing usable is configured", () => {
|
||||||
|
expect(driveSessionFromEnv({}).driveUrl).toBeNull();
|
||||||
|
expect(driveSessionFromEnv({ FILES_DRIVE_URL: "not a url" }).driveUrl).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
144
apps/web/src/app/(main)/chat/page.tsx
Normal file
144
apps/web/src/app/(main)/chat/page.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { EmbeddedApp } from "../../../components/embed/embedded-app";
|
||||||
|
import { api, errorMessage } from "../../../lib/api-client";
|
||||||
|
import { useApiResource } from "../../../lib/use-api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-app Chat: Element Web, full height inside the Hub shell, signed in as the
|
||||||
|
* Hub user without a Matrix password.
|
||||||
|
*
|
||||||
|
* Handshake (see infra/occupymedia/edge/hub-sso/chat-sso.html):
|
||||||
|
* 1. GET /matrix/identity → the MXID this Hub account maps to.
|
||||||
|
* 2. The frame opens Element's origin at /hub-sso.html#expect=<mxid>. If
|
||||||
|
* Element already holds a live session for that MXID it goes straight to
|
||||||
|
* Element — no token is minted, the device (and its E2EE keys) survives.
|
||||||
|
* 3. Otherwise the page posts `tmbh:chat-sso:need-token`; we POST
|
||||||
|
* /matrix/session and post the single-use m.login.token back, pinned to
|
||||||
|
* Element's origin. Element completes `/?loginToken=…` and keeps its own
|
||||||
|
* session from then on.
|
||||||
|
* Without SSO configured (or the API offline) the frame shows Element's own
|
||||||
|
* sign-in screen: sign in once there and it stays signed in.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Same host the nav linked to before Chat moved in-app. */
|
||||||
|
const FALLBACK_CHAT_URL = "https://chat.occupymedia.cc";
|
||||||
|
/** A frame that keeps asking for tokens is failing token login; stop minting. */
|
||||||
|
const MAX_MINTS = 3;
|
||||||
|
const ELEMENT_ALLOW = "camera; microphone; display-capture; fullscreen; autoplay; clipboard-read; clipboard-write";
|
||||||
|
|
||||||
|
interface MatrixIdentity {
|
||||||
|
configured: boolean;
|
||||||
|
homeserverUrl: string | null;
|
||||||
|
elementUrl: string | null;
|
||||||
|
userId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatrixSession {
|
||||||
|
homeserverUrl: string;
|
||||||
|
elementUrl: string;
|
||||||
|
userId: string;
|
||||||
|
loginToken: string;
|
||||||
|
expiresInMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const identityView = (identity: MatrixIdentity): MatrixIdentity | null => identity;
|
||||||
|
|
||||||
|
function isNeedToken(data: unknown): boolean {
|
||||||
|
return typeof data === "object" && data !== null && "type" in data && data.type === "tmbh:chat-sso:need-token";
|
||||||
|
}
|
||||||
|
|
||||||
|
function originOf(url: string): string | null {
|
||||||
|
try {
|
||||||
|
return new URL(url).origin;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatPage(): React.ReactElement {
|
||||||
|
const identity = useApiResource<MatrixIdentity, MatrixIdentity | null>("/matrix/identity", identityView, null);
|
||||||
|
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
const mints = useRef(0);
|
||||||
|
const tokenSent = useRef(false);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [mintError, setMintError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const elementOrigin = originOf(identity.data?.elementUrl ?? FALLBACK_CHAT_URL) ?? FALLBACK_CHAT_URL;
|
||||||
|
const ssoUser = identity.data?.configured === true ? identity.data.userId : null;
|
||||||
|
const src =
|
||||||
|
identity.source === "loading"
|
||||||
|
? null
|
||||||
|
: ssoUser !== null
|
||||||
|
? `${elementOrigin}/hub-sso.html#expect=${encodeURIComponent(ssoUser)}`
|
||||||
|
: `${elementOrigin}/`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ssoUser === null) return;
|
||||||
|
function onMessage(event: MessageEvent<unknown>): void {
|
||||||
|
const target = frameRef.current?.contentWindow ?? null;
|
||||||
|
if (target === null || event.source !== target || event.origin !== elementOrigin || !isNeedToken(event.data)) return;
|
||||||
|
if (mints.current >= MAX_MINTS) {
|
||||||
|
setBusy(null);
|
||||||
|
setMintError("Chat sign-in keeps failing. Open chat in a new tab, or retry.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mints.current += 1;
|
||||||
|
setBusy("Signing in to chat…");
|
||||||
|
api
|
||||||
|
.post<MatrixSession>("/matrix/session")
|
||||||
|
.then((session) => {
|
||||||
|
const current = frameRef.current?.contentWindow ?? null;
|
||||||
|
if (current === null) return;
|
||||||
|
tokenSent.current = true;
|
||||||
|
current.postMessage({ type: "tmbh:chat-sso:token", loginToken: session.loginToken }, elementOrigin);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
setBusy(null);
|
||||||
|
setMintError(errorMessage(err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.addEventListener("message", onMessage);
|
||||||
|
return () => window.removeEventListener("message", onMessage);
|
||||||
|
}, [ssoUser, elementOrigin]);
|
||||||
|
|
||||||
|
// The load after a token was handed over is Element itself: sign-in is done.
|
||||||
|
const handleFrameLoad = useCallback(() => {
|
||||||
|
if (!tokenSent.current) return;
|
||||||
|
tokenSent.current = false;
|
||||||
|
setBusy(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRetry = useCallback(() => {
|
||||||
|
mints.current = 0;
|
||||||
|
tokenSent.current = false;
|
||||||
|
setBusy(null);
|
||||||
|
setMintError(null);
|
||||||
|
if (identity.error !== null) identity.reload();
|
||||||
|
}, [identity]);
|
||||||
|
|
||||||
|
const notice =
|
||||||
|
identity.source === "sample"
|
||||||
|
? "Hub offline — sign in to chat directly."
|
||||||
|
: identity.source === "live" && identity.error === null && ssoUser === null
|
||||||
|
? "Automatic sign-in is not set up — sign in once here and chat stays signed in."
|
||||||
|
: ssoUser !== null
|
||||||
|
? ssoUser
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmbeddedApp
|
||||||
|
title="Chat"
|
||||||
|
src={identity.error === null ? src : null}
|
||||||
|
externalHref={`${elementOrigin}/`}
|
||||||
|
busyLabel={busy}
|
||||||
|
error={identity.error ?? mintError}
|
||||||
|
notice={notice}
|
||||||
|
frameRef={frameRef}
|
||||||
|
onFrameLoad={handleFrameLoad}
|
||||||
|
onRetry={handleRetry}
|
||||||
|
allow={ELEMENT_ALLOW}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
apps/web/src/app/(main)/files/drive/page.tsx
Normal file
57
apps/web/src/app/(main)/files/drive/page.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { EmbeddedApp } from "../../../../components/embed/embedded-app";
|
||||||
|
import { useFilesLang } from "../../../../components/files/i18n";
|
||||||
|
import { useApiResource } from "../../../../lib/use-api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive — the oCIS web UI (personal space, shares, office documents) embedded
|
||||||
|
* full height under the Files tabs. The group Library tab keeps the Hub-owned
|
||||||
|
* features (upload into a collective, share with a collective, retention).
|
||||||
|
*
|
||||||
|
* Sign-in follows GET /drive/session's authMode: "interactive" means oCIS's
|
||||||
|
* own identity provider — the user signs in once inside the frame and oCIS
|
||||||
|
* web keeps its tokens (tokenStorageLocal) on the files origin, which this
|
||||||
|
* frame shares because app.* and files.* are the same site. "oidc" means oCIS
|
||||||
|
* and the Hub share an issuer and the frame signs in silently.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Same host FILES_PUBLIC_BASE defaults to; used only while the API is offline. */
|
||||||
|
const FALLBACK_DRIVE_URL = "https://files.occupymedia.cc";
|
||||||
|
|
||||||
|
interface DriveSession {
|
||||||
|
driveUrl: string | null;
|
||||||
|
authMode: "interactive" | "oidc";
|
||||||
|
}
|
||||||
|
|
||||||
|
const driveView = (session: DriveSession): DriveSession | null => session;
|
||||||
|
|
||||||
|
export default function DrivePage(): React.ReactElement {
|
||||||
|
const { t } = useFilesLang();
|
||||||
|
const drive = useApiResource<DriveSession, DriveSession | null>("/drive/session", driveView, null);
|
||||||
|
|
||||||
|
const driveUrl = drive.source === "sample" ? FALLBACK_DRIVE_URL : drive.data?.driveUrl ?? null;
|
||||||
|
const notConfigured = drive.source === "live" && drive.error === null && driveUrl === null;
|
||||||
|
const error =
|
||||||
|
drive.error ??
|
||||||
|
(notConfigured ? t({ en: "The drive is not configured on this server (FILES_DRIVE_URL).", es: "La unidad no está configurada en este servidor (FILES_DRIVE_URL)." }) : null);
|
||||||
|
const notice =
|
||||||
|
drive.data?.authMode === "oidc"
|
||||||
|
? t({ en: "Collective files live in the Library tab.", es: "Los archivos de los colectivos están en la pestaña Biblioteca." })
|
||||||
|
: t({
|
||||||
|
en: "Sign in once inside the drive — it stays signed in. Collective files live in the Library tab.",
|
||||||
|
es: "Inicia sesión una vez dentro de la unidad y quedará abierta. Los archivos de los colectivos están en la pestaña Biblioteca.",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmbeddedApp
|
||||||
|
title={t({ en: "Drive", es: "Unidad" })}
|
||||||
|
src={drive.source === "loading" || error !== null ? null : `${driveUrl ?? FALLBACK_DRIVE_URL}/`}
|
||||||
|
externalHref={`${driveUrl ?? FALLBACK_DRIVE_URL}/`}
|
||||||
|
error={error}
|
||||||
|
notice={notice}
|
||||||
|
onRetry={drive.error !== null ? drive.reload : undefined}
|
||||||
|
allow="fullscreen; clipboard-read; clipboard-write"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,12 +2,21 @@
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { FilesLangProvider } from "../../../components/files/i18n";
|
import { FilesLangProvider } from "../../../components/files/i18n";
|
||||||
|
import { FilesTabs } from "../../../components/files/files-tabs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Files shell. Like the wiki, the language lives in localStorage per browser
|
* Files shell. Like the wiki, the language lives in localStorage per browser
|
||||||
* (lang-context explains why it is not an account setting) and is provided here
|
* (lang-context explains why it is not an account setting) and is provided here
|
||||||
* so every Files screen and sub-component reads it through one context.
|
* so every Files screen and sub-component reads it through one context. The tab
|
||||||
|
* strip switches between the group Library and the embedded oCIS Drive.
|
||||||
*/
|
*/
|
||||||
export default function FilesLayout({ children }: { children: ReactNode }): React.ReactElement {
|
export default function FilesLayout({ children }: { children: ReactNode }): React.ReactElement {
|
||||||
return <FilesLangProvider>{children}</FilesLangProvider>;
|
return (
|
||||||
|
<FilesLangProvider>
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
<FilesTabs />
|
||||||
|
<div className="min-h-0 flex-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
</FilesLangProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { useIsMobile } from "../../hooks/use-is-mobile";
|
||||||
const titles: Record<string, string> = {
|
const titles: Record<string, string> = {
|
||||||
streams: "Home", search: "Search", inbox: "Inbox", approvals: "Approvals", more: "More", compose: "New post",
|
streams: "Home", search: "Search", inbox: "Inbox", approvals: "Approvals", more: "More", compose: "New post",
|
||||||
calendar: "Calendar", video: "Video", analytics: "Analytics", connections: "Connections", team: "Team",
|
calendar: "Calendar", video: "Video", analytics: "Analytics", connections: "Connections", team: "Team",
|
||||||
audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive", files: "Files",
|
audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive", files: "Files", chat: "Chat",
|
||||||
};
|
};
|
||||||
const rootTabs = new Set(["/streams", "/search", "/inbox", "/more"]);
|
const rootTabs = new Set(["/streams", "/search", "/inbox", "/more"]);
|
||||||
const scrollPositions = new Map<string, { main: number; nested: number[] }>();
|
const scrollPositions = new Map<string, { main: number; nested: number[] }>();
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ export default function MorePage(): React.ReactElement {
|
||||||
<Row href="/analytics" title="Analytics" />
|
<Row href="/analytics" title="Analytics" />
|
||||||
<Row href="/video" title="Video library" />
|
<Row href="/video" title="Video library" />
|
||||||
<Row href="/files" title="Files" detail="Group file library" />
|
<Row href="/files" title="Files" detail="Group file library" />
|
||||||
<Row href="https://element.occupymedia.cc" title="Chat" detail="Matrix chat" external />
|
<Row href="/chat" title="Chat" detail="Matrix chat" />
|
||||||
<Row href="/onboarding" title="Onboarding" />
|
<Row href="/onboarding" title="Onboarding" />
|
||||||
<button onClick={() => void logout()} className="flex min-h-[56px] w-full items-center px-4 text-left text-sm font-medium text-danger">Sign out</button>
|
<button onClick={() => void logout()} className="flex min-h-[56px] w-full items-center px-4 text-left text-sm font-medium text-danger">Sign out</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
121
apps/web/src/components/embed/embedded-app.tsx
Normal file
121
apps/web/src/components/embed/embedded-app.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, type ReactNode, type Ref } from "react";
|
||||||
|
import { Button } from "../ui/primitives";
|
||||||
|
import { Loading } from "../brand/loading";
|
||||||
|
|
||||||
|
/** A frame that has not fired `load` by then is reported as unreachable. */
|
||||||
|
const LOAD_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-height in-app view of a sibling occupymedia.cc service (Element, oCIS
|
||||||
|
* web) — the Hub sidebar stays put and only <main> hosts the frame.
|
||||||
|
*
|
||||||
|
* The frame mounts once per `src` and again on every Retry; `busyLabel` keeps the overlay up
|
||||||
|
* across in-frame navigations the parent knows about (e.g. Element's token
|
||||||
|
* sign-in redirect). A frame that never loads within LOAD_TIMEOUT_MS shows the
|
||||||
|
* error panel. A frame refused by the target's CSP / X-Frame-Options still
|
||||||
|
* fires `load` (with the browser's block page), so "Open in new tab" stays in
|
||||||
|
* the header permanently rather than only in the error panel.
|
||||||
|
*/
|
||||||
|
export function EmbeddedApp({
|
||||||
|
title,
|
||||||
|
src,
|
||||||
|
externalHref,
|
||||||
|
busyLabel = null,
|
||||||
|
error = null,
|
||||||
|
notice,
|
||||||
|
frameRef,
|
||||||
|
onFrameLoad,
|
||||||
|
onRetry,
|
||||||
|
allow,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
/** null while the parent is still resolving where to point the frame. */
|
||||||
|
src: string | null;
|
||||||
|
externalHref: string;
|
||||||
|
busyLabel?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
notice?: ReactNode;
|
||||||
|
frameRef?: Ref<HTMLIFrameElement>;
|
||||||
|
onFrameLoad?: () => void;
|
||||||
|
/** Called on Retry, before the frame remounts — clear parent-side errors here. */
|
||||||
|
onRetry?: () => void;
|
||||||
|
allow?: string;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [timedOut, setTimedOut] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoaded(false);
|
||||||
|
setTimedOut(false);
|
||||||
|
if (src === null) return;
|
||||||
|
const timer = window.setTimeout(() => setTimedOut(true), LOAD_TIMEOUT_MS);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [src, attempt]);
|
||||||
|
|
||||||
|
function retry(): void {
|
||||||
|
onRetry?.();
|
||||||
|
setAttempt((n) => n + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const failure = error ?? (timedOut && !loaded ? `${title} did not respond. It may be down or blocking embedding.` : null);
|
||||||
|
const overlay = failure === null && (src === null || !loaded || busyLabel !== null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border-subtle bg-surface px-4 py-2">
|
||||||
|
<h1 className="text-sm font-semibold">{title}</h1>
|
||||||
|
{notice !== undefined && <div className="min-w-0 text-2xs text-text-faint">{notice}</div>}
|
||||||
|
<a
|
||||||
|
href={externalHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="ml-auto inline-flex min-h-8 items-center gap-1 rounded-md px-2 text-xs text-text-dim transition-colors duration-150 hover:bg-surface-2 hover:text-text"
|
||||||
|
>
|
||||||
|
Open in new tab <span aria-hidden>↗</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="relative min-h-0 flex-1 bg-bg">
|
||||||
|
{src !== null && failure === null && (
|
||||||
|
<iframe
|
||||||
|
key={attempt}
|
||||||
|
ref={frameRef}
|
||||||
|
src={src}
|
||||||
|
title={title}
|
||||||
|
allow={allow}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className="absolute inset-0 h-full w-full border-0"
|
||||||
|
onLoad={() => {
|
||||||
|
setLoaded(true);
|
||||||
|
onFrameLoad?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{overlay && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-bg">
|
||||||
|
<Loading size={32} label={busyLabel ?? `Loading ${title}…`} />
|
||||||
|
<p className="text-xs text-text-faint">{busyLabel ?? `Loading ${title}…`}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{failure !== null && (
|
||||||
|
<div role="alert" className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
<p className="max-w-md text-sm text-danger">{failure}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={retry}>Retry</Button>
|
||||||
|
<a
|
||||||
|
href={externalHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
Open in new tab ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
apps/web/src/components/files/files-tabs.tsx
Normal file
38
apps/web/src/components/files/files-tabs.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useFilesLang, type LocalizedText } from "./i18n";
|
||||||
|
|
||||||
|
const TABS: { href: string; label: LocalizedText }[] = [
|
||||||
|
{ href: "/files", label: { en: "Library", es: "Biblioteca" } },
|
||||||
|
{ href: "/files/drive", label: { en: "Drive", es: "Unidad" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files section switcher: the Hub's group library (upload, share with a
|
||||||
|
* collective, retention) and the embedded oCIS drive side by side.
|
||||||
|
*/
|
||||||
|
export function FilesTabs(): React.ReactElement {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { t } = useFilesLang();
|
||||||
|
return (
|
||||||
|
<nav aria-label={t({ en: "Files views", es: "Vistas de archivos" })} className="flex shrink-0 gap-1 border-b border-border-subtle bg-surface px-3 pt-2">
|
||||||
|
{TABS.map((tab) => {
|
||||||
|
const active = tab.href === "/files" ? pathname === "/files" : pathname.startsWith(tab.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.href}
|
||||||
|
href={tab.href}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={`-mb-px min-h-9 border-b-2 px-3 py-2 text-xs font-medium transition-colors duration-150 ${
|
||||||
|
active ? "border-accent text-accent" : "border-transparent text-text-dim hover:text-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(tab.label)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,8 @@ const PAGES = [
|
||||||
{ href: "/inbox", label: "Go to Inbox" },
|
{ href: "/inbox", label: "Go to Inbox" },
|
||||||
{ href: "/archive", label: "Go to Archive" },
|
{ href: "/archive", label: "Go to Archive" },
|
||||||
{ href: "/files", label: "Go to Files" },
|
{ href: "/files", label: "Go to Files" },
|
||||||
|
{ href: "/files/drive", label: "Go to Drive" },
|
||||||
|
{ href: "/chat", label: "Go to Chat" },
|
||||||
{ href: "/connections", label: "Go to Channels" },
|
{ href: "/connections", label: "Go to Channels" },
|
||||||
{ href: "/team", label: "Go to Team" },
|
{ href: "/team", label: "Go to Team" },
|
||||||
{ href: "/audit", label: "Go to Audit" },
|
{ href: "/audit", label: "Go to Audit" },
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ const NAV: { href: string; label: string; icon: string; external?: boolean }[] =
|
||||||
{ href: "/control-room", label: "Control Room", icon: "video" },
|
{ href: "/control-room", label: "Control Room", icon: "video" },
|
||||||
{ href: "/compose", label: "Compose", icon: "compose" },
|
{ href: "/compose", label: "Compose", icon: "compose" },
|
||||||
{ href: "/calendar", label: "Calendar", icon: "calendar" },
|
{ href: "/calendar", label: "Calendar", icon: "calendar" },
|
||||||
{ href: "https://chat.occupymedia.cc", label: "Chat", icon: "chat", external: true },
|
{ href: "/chat", label: "Chat", icon: "chat" },
|
||||||
{ href: "/files", label: "Files", icon: "files" },
|
{ href: "/files", label: "Files", icon: "files" },
|
||||||
{ href: "/archive", label: "Archive", icon: "files" },
|
{ href: "/archive", label: "Archive", icon: "files" },
|
||||||
{ href: "/inbox", label: "Inbox", icon: "inbox" },
|
{ href: "/inbox", label: "Inbox", icon: "inbox" },
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,14 @@ services:
|
||||||
ARCHIVE_PROVISIONER_HOMESERVER_URL: ${ARCHIVE_PROVISIONER_HOMESERVER_URL:-}
|
ARCHIVE_PROVISIONER_HOMESERVER_URL: ${ARCHIVE_PROVISIONER_HOMESERVER_URL:-}
|
||||||
ARCHIVE_PROVISIONER_USER: ${ARCHIVE_PROVISIONER_USER:-}
|
ARCHIVE_PROVISIONER_USER: ${ARCHIVE_PROVISIONER_USER:-}
|
||||||
ARCHIVE_PROVISIONER_PASSWORD: ${ARCHIVE_PROVISIONER_PASSWORD:-}
|
ARCHIVE_PROVISIONER_PASSWORD: ${ARCHIVE_PROVISIONER_PASSWORD:-}
|
||||||
|
# In-app Chat sign-in: same secret as [global.jwt] key in tuwunel.toml.
|
||||||
|
# Unset = the Chat view falls back to Element's own sign-in screen.
|
||||||
|
MATRIX_SSO_JWT_SECRET: ${MATRIX_SSO_JWT_SECRET:-}
|
||||||
|
MATRIX_SSO_HOMESERVER_URL: ${MATRIX_SSO_HOMESERVER_URL:-https://chat.occupymedia.cc}
|
||||||
|
MATRIX_SSO_LOCALPART_PREFIX: ${MATRIX_SSO_LOCALPART_PREFIX:-}
|
||||||
|
# In-app Drive: oCIS web origin + auth mode (interactive | oidc).
|
||||||
|
FILES_DRIVE_URL: ${FILES_DRIVE_URL:-https://files.occupymedia.cc}
|
||||||
|
FILES_DRIVE_AUTH: ${FILES_DRIVE_AUTH:-interactive}
|
||||||
ports: ["3000:3000"]
|
ports: ["3000:3000"]
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres: { condition: service_healthy }
|
postgres: { condition: service_healthy }
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
- /opt/occ-brand:/opt/occ-brand:ro
|
- /opt/occ-brand:/opt/occ-brand:ro
|
||||||
|
- ./hub-sso:/opt/hub-sso:ro
|
||||||
- ./certs:/etc/letsencrypt:ro
|
- ./certs:/etc/letsencrypt:ro
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|
|
||||||
63
infra/occupymedia/edge/hub-sso/chat-sso.html
Normal file
63
infra/occupymedia/edge/hub-sso/chat-sso.html
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<!doctype html>
|
||||||
|
<!--
|
||||||
|
Hub → Element sign-in bootstrap, served by the edge at
|
||||||
|
https://chat.occupymedia.cc/hub-sso.html (same origin as Element Web).
|
||||||
|
|
||||||
|
Element only completes `/?loginToken=…` when localStorage["mx_sso_hs_url"]
|
||||||
|
was set on its own origin beforehand (Lifecycle.attemptTokenLogin — normally
|
||||||
|
Element writes it when IT starts an SSO redirect). The Hub cannot write
|
||||||
|
another origin's storage, so this page does it, then hands over.
|
||||||
|
|
||||||
|
Protocol (window.postMessage, both directions origin-pinned):
|
||||||
|
page → Hub { type: "tmbh:chat-sso:need-token" }
|
||||||
|
Hub → page { type: "tmbh:chat-sso:token", loginToken }
|
||||||
|
The Hub frames this page as /hub-sso.html#expect=<mxid>; when Element
|
||||||
|
already holds a live session for that MXID no token is requested at all, so
|
||||||
|
re-opening Chat never replaces the device (and its E2EE keys).
|
||||||
|
-->
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Signing in to chat…</title>
|
||||||
|
<style>html,body{margin:0;height:100%;background:#000;color:#9a9a9a;font:14px system-ui,sans-serif;display:grid;place-items:center}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>Signing in to chat…</p>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var HUB_ORIGINS = ["https://app.occupymedia.cc"];
|
||||||
|
// Element and the homeserver share this host (/_matrix is proxied here).
|
||||||
|
var HOMESERVER = location.origin;
|
||||||
|
|
||||||
|
function openElement(query) {
|
||||||
|
location.replace("/" + query);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opened directly rather than framed by the Hub: plain Element.
|
||||||
|
if (window.parent === window) return openElement("");
|
||||||
|
|
||||||
|
var expected = new URLSearchParams(location.hash.slice(1)).get("expect");
|
||||||
|
var signedInAs = localStorage.getItem("mx_user_id");
|
||||||
|
var live = localStorage.getItem("mx_has_access_token") === "true" && localStorage.getItem("mx_soft_logout") !== "true";
|
||||||
|
if (expected !== null && expected === signedInAs && live) return openElement("");
|
||||||
|
|
||||||
|
var done = false;
|
||||||
|
window.addEventListener("message", function (event) {
|
||||||
|
if (done || event.source !== window.parent || HUB_ORIGINS.indexOf(event.origin) === -1) return;
|
||||||
|
var data = event.data;
|
||||||
|
if (data === null || typeof data !== "object" || data.type !== "tmbh:chat-sso:token" || typeof data.loginToken !== "string") return;
|
||||||
|
done = true;
|
||||||
|
localStorage.setItem("mx_sso_hs_url", HOMESERVER);
|
||||||
|
localStorage.removeItem("mx_sso_is_url");
|
||||||
|
openElement("?loginToken=" + encodeURIComponent(data.loginToken));
|
||||||
|
});
|
||||||
|
// A non-matching targetOrigin is dropped by the browser, never delivered.
|
||||||
|
HUB_ORIGINS.forEach(function (origin) {
|
||||||
|
window.parent.postMessage({ type: "tmbh:chat-sso:need-token" }, origin);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -118,6 +118,14 @@ http {
|
||||||
default_type application/json;
|
default_type application/json;
|
||||||
return 200 '{"m.homeserver":{"base_url":"https://chat.occupymedia.cc"}}';
|
return 200 '{"m.homeserver":{"base_url":"https://chat.occupymedia.cc"}}';
|
||||||
}
|
}
|
||||||
|
# Hub in-app Chat: sign-in bootstrap on Element's own origin (see hub-sso/chat-sso.html).
|
||||||
|
location = /hub-sso.html {
|
||||||
|
alias /opt/hub-sso/chat-sso.html;
|
||||||
|
default_type text/html;
|
||||||
|
add_header X-Served-By occ-edge always;
|
||||||
|
add_header Cache-Control "no-store" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-ancestors https://app.occupymedia.cc" always;
|
||||||
|
}
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://occ-element:80;
|
proxy_pass http://occ-element:80;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
@ -154,6 +162,14 @@ http {
|
||||||
default_type application/json;
|
default_type application/json;
|
||||||
return 200 '{"m.server":"chat.occupymedia.cc:443"}';
|
return 200 '{"m.server":"chat.occupymedia.cc:443"}';
|
||||||
}
|
}
|
||||||
|
# Hub in-app Chat: sign-in bootstrap on Element's own origin (see hub-sso/chat-sso.html).
|
||||||
|
location = /hub-sso.html {
|
||||||
|
alias /opt/hub-sso/chat-sso.html;
|
||||||
|
default_type text/html;
|
||||||
|
add_header X-Served-By occ-edge always;
|
||||||
|
add_header Cache-Control "no-store" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-ancestors https://app.occupymedia.cc" always;
|
||||||
|
}
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://occ-element:80;
|
proxy_pass http://occ-element:80;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
@ -189,6 +205,9 @@ http {
|
||||||
}
|
}
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://192.168.13.210:9200;
|
proxy_pass http://192.168.13.210:9200;
|
||||||
|
# Hub in-app Drive frames oCIS web from app.occupymedia.cc: framing policy
|
||||||
|
# lives in oCIS csp.yaml (frame-ancestors); drop the legacy SAMEORIGIN.
|
||||||
|
proxy_hide_header X-Frame-Options;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
@ -220,6 +239,9 @@ http {
|
||||||
}
|
}
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://192.168.13.210:9200;
|
proxy_pass http://192.168.13.210:9200;
|
||||||
|
# Hub in-app Drive frames oCIS web from app.occupymedia.cc: framing policy
|
||||||
|
# lives in oCIS csp.yaml (frame-ancestors); drop the legacy SAMEORIGIN.
|
||||||
|
proxy_hide_header X-Frame-Options;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ directives:
|
||||||
child-src: ["'self'"]
|
child-src: ["'self'"]
|
||||||
connect-src: ["'self'", "blob:"]
|
connect-src: ["'self'", "blob:"]
|
||||||
font-src: ["'self'", "data:"]
|
font-src: ["'self'", "data:"]
|
||||||
frame-ancestors: ["'self'"]
|
# The Hub's in-app Drive view (app.occupymedia.cc/files/drive) frames oCIS web.
|
||||||
|
frame-ancestors: ["'self'", "https://app.occupymedia.cc"]
|
||||||
frame-src: ["'self'", "blob:", "https://${OFFICE_HOST}"]
|
frame-src: ["'self'", "blob:", "https://${OFFICE_HOST}"]
|
||||||
img-src: ["'self'", "data:", "blob:", "https://${OFFICE_HOST}"]
|
img-src: ["'self'", "data:", "blob:", "https://${OFFICE_HOST}"]
|
||||||
manifest-src: ["'self'"]
|
manifest-src: ["'self'"]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue