Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ae1e62b7b | ||
|
|
47cbc7c72d | ||
|
|
1ae6508793 | ||
|
|
03407ae79a | ||
|
|
9d6767e731 | ||
|
|
aa6a4a3b58 | ||
|
|
d9a46afbf9 | ||
|
|
b5308a8bab | ||
|
|
0caaa3717e | ||
|
|
464db76bc3 | ||
|
|
19a06166f5 | ||
|
|
61060e2793
|
||
|
|
4e88ed8303 | ||
|
|
7a7b804106
|
||
|
|
4c95151a86 | ||
|
|
41bbf2c428
|
||
|
|
2f6be765f2
|
||
|
|
92dd901409 | ||
|
|
6a4ea584f5
|
||
|
|
03b3bbaca2 |
No files matched your search
+11
-57
@@ -1,60 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,25 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -13,6 +13,9 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
|
||||
|
||||
### Changed
|
||||
|
||||
- UI API extension collaboration 1.2.0, additive (#54): presence viewers carry `connections` (live leases merged into the viewer), snapshots carry `leaseCount`, and `GET /presence?perConnection=true` adds `leases`, one viewer per connection with an opaque `connectionId`, `mode`, `fieldName`, `sessionId` and `since`. `presence.changed` events always carry `leaseCount` and `leases`. Leases keep an opaque HMAC-SHA256 session id (secret `QQQ_PRESENCE_SESSION_SECRET` or random per process), never the session uuid, which is a bearer credential. `PresenceRegistry.connectionCounts()` counts leases next to the unchanged user `counts()`; the Who's here card shows both.
|
||||
- UI API extension collaboration 1.1.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags and published presence request/snapshot/viewer schemas to resolve dangling references. Retain CollaborationErrorV1 as deprecated.
|
||||
|
||||
- Upgrade the default QQQ BOM to `4.1.0-RC.7` from Maven Central.
|
||||
- Register the core followers provider for alerts and other qbits, with event preferences, future-mute exclusion, system reads scoped to one record, and caller-owned recipient access checks.
|
||||
- Cache favorite/recent record labels only for FULL field access. Resolve comment/share notification labels per recipient with a table-label-and-id fallback. Use the typed core label-access API for email labels as well.
|
||||
@@ -24,6 +27,9 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
|
||||
|
||||
### Fixed
|
||||
|
||||
- QBit metadata reports the Maven artifact version from a filtered resource, including release versions.
|
||||
- `presence.changed` events and presence snapshots always carry `viewers`, sending `[]` once the last viewer leaves, as the schema requires (#52).
|
||||
|
||||
- Timeline audit summaries read "Edited by Alice Morgan: Title, Total" or "Created by Alice Morgan" instead of "Record was Edited"; detail rows are unchanged.
|
||||
- The demo server chooses its user per browser session (`?user=bob`, remembered in a cookie) instead of once per JVM, and sends the person's name to the frontend so it no longer shows Anonymous.
|
||||
- The share recipient search and mention candidates no longer offer the caller to themself.
|
||||
|
||||
@@ -410,13 +410,13 @@ The runtime provider adds this field and its search entry where TAGS is enabled.
|
||||
|
||||
## UI API
|
||||
|
||||
The producer registers extension `collaboration` version `1.0.0` at
|
||||
The producer registers extension `collaboration` version `1.2.0` at
|
||||
`/qqq/v1/qbit/collaboration`. It uses the signed-in user's session and existing
|
||||
services. Disable registration with `new CollaborationQBitConfig().withUiApi(false)`.
|
||||
Presence, shares, tasks, my-work, direct upload and thumbnail routes are not part
|
||||
of this extension version.
|
||||
Requires QQQ `4.1.0-RC.15` and UI API `1.6.0`. Operations carry stable operationIds,
|
||||
the collaboration tag and standard error responses.
|
||||
|
||||
All successes return HTTP 200. All bodies have named `Collaboration*V1` schemas
|
||||
All successes return HTTP 200. Success and request bodies have named `Collaboration*V1` schemas
|
||||
in [the extension contract](src/main/resources/openapi/qqq-v1/collaboration.yaml).
|
||||
The server publishes these alongside core at `/qqq/v1/openapi.yaml` and `.json`.
|
||||
|
||||
@@ -478,9 +478,11 @@ attachment filename; preview requests inline content. QQQ's current OpenAPI
|
||||
`Response` model cannot declare the `Location` header, so this contract uses the
|
||||
JSON URL alternative consistently.
|
||||
|
||||
A missing or invisible parent returns identical `404 {"error":"Not found"}`.
|
||||
Missing write permissions return 403; invalid input returns 400 with an `error`
|
||||
message through QQQ's exception mapper. Provider/backend failures remain server
|
||||
A missing or invisible parent returns 404 with the same `Not found` error message.
|
||||
Missing write permissions return 403; invalid input returns 400 through QQQ's
|
||||
exception mapper. Errors use `BasicErrorResponseV1` with `error`, `code`, `message`,
|
||||
`traceId` and optional `fieldErrors`. `CollaborationErrorV1` remains defined but is
|
||||
deprecated since 1.1.0. Provider/backend failures remain server
|
||||
errors. Services continue to own author, record-lock and permission checks.
|
||||
Mention candidates expose `canRead` as the string enum `"true"`, `"false"`,
|
||||
`"unknown"`; unknown does not imply the recipient can read the record.
|
||||
@@ -636,7 +638,7 @@ New routes, all under `/qqq/v1/qbit/collaboration`:
|
||||
| PUT | `/records/{table}/{pk}/assignment` |
|
||||
| GET | `/my-work` |
|
||||
|
||||
Typed `CollaborationWorkSchemas` describe requests and results. Task PATCH preserves omitted fields, accepts OPEN/DONE/CANCELLED transitions, and permits explicit null for optional fields; null `remindAt` selects the service's due-date default. Assignment PUT replaces owner and assignee; null clears. Invalid input is 400, denied visible-record operations are 403, and missing/invisible parents are 404. Access requests deliberately never inspect parent existence. The extension stays at unreleased **1.0.0**; contract tooling accepts these additions without a bump.
|
||||
Typed `CollaborationWorkSchemas` describe requests and results. Task PATCH preserves omitted fields, accepts OPEN/DONE/CANCELLED transitions, and permits explicit null for optional fields; null `remindAt` selects the service's due-date default. Assignment PUT replaces owner and assignee; null clears. Invalid input is 400, denied visible-record operations are 403, and missing/invisible parents are 404. Access requests deliberately never inspect parent existence. The extension is now **1.1.0**, including the UI API 1.6.0 error contract and published presence schemas.
|
||||
|
||||
`CollaborationRealtime` publishes `comment.added/updated/deleted`, `attachment.changed`, `tags.changed` and `task.changed` on `record:{table}:{pk}` after commit. Successful notification delivery publishes `notification.created` on `user:{userId}`. Payloads contain identifiers only. `CollaborationFollowersProvider` supplies the core followers SPI for alerts and other consumers. Selectable Tags values remain unavailable because the overlay has no output hook.
|
||||
|
||||
@@ -719,7 +721,7 @@ Enable `RecordFeature.PRESENCE` on the table. The typed endpoints below
|
||||
| --- | --- |
|
||||
| `POST /presence` | `{connectionId, table, pk?, mode: "VIEWING"|"EDITING", fieldName?}` |
|
||||
| `DELETE /presence` | `{connectionId, table, pk?}` |
|
||||
| `GET /presence?table=...&pk=...` | Omit pk for a table snapshot |
|
||||
| `GET /presence?table=...&pk=...&perConnection=true` | Omit pk for a table snapshot; `perConnection` is optional |
|
||||
|
||||
Session identity supplies userId and displayName. Every call rechecks table READ and record
|
||||
GetAction visibility. Disabled features and denied/missing parents return the same 404. A
|
||||
@@ -731,6 +733,17 @@ sweep and expiry checks on reads. The cap is 50,000 leases per pod: new targets
|
||||
a warning while existing leases can renew. Tabs merge per user; EDITING wins and the latest
|
||||
editor supplies focus. Nothing is persisted, including directory rows.
|
||||
|
||||
Per-connection presence (extension 1.2.0, #54): each merged viewer carries `connections`, the
|
||||
number of live leases (tabs, terminals) behind it, and every snapshot carries `leaseCount`, so
|
||||
two terminals of one user read as one person with two connections. `perConnection=true` adds
|
||||
`leases`, one viewer per lease with an opaque `connectionId` (`c` and the first 16 hex digits of
|
||||
SHA-256 of `userId`, a newline and the client connection id), its `mode`, `fieldName` (record
|
||||
targets only), `sessionId` and `since`, the lease start. `presence.changed` events always carry
|
||||
`leaseCount` and `leases`. Session uuids are bearer credentials, so a lease never stores or
|
||||
publishes one: it keeps `sessionId`, `s` and the first 16 hex digits of HMAC-SHA256 of the
|
||||
session uuid under `QQQ_PRESENCE_SESSION_SECRET` (system property or environment; set the same
|
||||
value on every pod so ids agree) or else a random per-process secret. `PresenceRegistry.counts()` still counts users; `connectionCounts()` counts leases.
|
||||
|
||||
Every heartbeat/leave replicates over core's bus on `instance:presence`, a channel refused by
|
||||
core client subscriptions. `presence.changed` is emitted to record and table channels. Table
|
||||
snapshots aggregate users without record ids or focused field names; those stay on record
|
||||
@@ -739,5 +752,5 @@ refetch after reconnecting.
|
||||
|
||||
The initial overlay refresh starts the registry on every pod, including those without local
|
||||
viewers. Endpoints also initialize it lazily. At shutdown call `PresenceRegistry.close(instance)`
|
||||
before `RealtimeBus.close(instance)`. The admin-only **Who's here** home card shows user counts
|
||||
per table, and core record-feature summaries include the presence snapshot.
|
||||
before `RealtimeBus.close(instance)`. The admin-only **Who's here** home card shows user and
|
||||
connection counts per table, and core record-feature summaries include the presence snapshot.
|
||||
@@ -120,6 +120,7 @@
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
<include>qbit-collaboration.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
@@ -127,6 +128,7 @@
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
<exclude>qbit-collaboration.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
@@ -223,14 +225,14 @@
|
||||
<profiles>
|
||||
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
|
||||
<!-- imports the released 4.1 BOM, this profile is active by default and -->
|
||||
<!-- imports qqq-bom-pom 4.1.0-RC.7 ahead of the parent's 4.0.0 BOM. -->
|
||||
<!-- imports qqq-bom-pom 4.1.0-RC.15 ahead of the parent's 4.0.0 BOM. -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
package com.kingsrook.qbits.collaboration;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
|
||||
import com.kingsrook.qbits.collaboration.attachments.DeleteAttachmentsProcess;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
@@ -44,13 +47,41 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
|
||||
{
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-collaboration";
|
||||
public static final String VERSION = "0.1.0";
|
||||
public static final String VERSION = loadVersion();
|
||||
|
||||
private CollaborationQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the artifact version from the Maven-filtered properties resource.
|
||||
*******************************************************************************/
|
||||
private static String loadVersion()
|
||||
{
|
||||
try(InputStream inputStream = CollaborationQBitProducer.class.getClassLoader().getResourceAsStream("qbit-collaboration.properties"))
|
||||
{
|
||||
if(inputStream == null)
|
||||
{
|
||||
throw new IllegalStateException("Missing qbit-collaboration.properties; build with Maven to generate the artifact version.");
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
properties.load(inputStream);
|
||||
String version = properties.getProperty("version");
|
||||
if(version == null || version.isBlank() || version.contains("${"))
|
||||
{
|
||||
throw new IllegalStateException("Missing or unfiltered version in qbit-collaboration.properties; build with Maven resource filtering.");
|
||||
}
|
||||
return (version);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw new IllegalStateException("Could not read the artifact version from qbit-collaboration.properties.", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure the qbit.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -56,6 +56,7 @@ public class CollaborationSummaryProvider implements RecordFeatureSummaryProvide
|
||||
entry.put("displayName", viewer.displayName());
|
||||
entry.put("mode", viewer.mode());
|
||||
entry.put("fieldName", viewer.fieldName());
|
||||
entry.put("connections", viewer.connections());
|
||||
return (entry);
|
||||
}).toList());
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ public final class CollaborationApi
|
||||
*******************************************************************************/
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.0.0")
|
||||
return (new MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.2.0")
|
||||
.withDescription("Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence")
|
||||
.withSpec(PostPresenceSpec.class).withSpec(DeletePresenceSpec.class).withSpec(GetPresenceSpec.class)
|
||||
.withSpec(ListFavoritesSpec.class)
|
||||
|
||||
@@ -46,6 +46,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.In;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
@@ -112,10 +113,10 @@ abstract class CollaborationJsonSpec extends AbstractJsonEndpointSpec
|
||||
*******************************************************************************/
|
||||
static List<BasicResponse> errors()
|
||||
{
|
||||
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", "CollaborationErrorV1"),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "CollaborationErrorV1"),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", "CollaborationErrorV1"),
|
||||
new BasicResponse(HttpStatus.NOT_FOUND, "Not found (including invisible parents)", "CollaborationErrorV1")));
|
||||
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.NOT_FOUND, "Not found (including invisible parents)", BasicErrorResponseV1.class.getSimpleName())));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -160,13 +161,14 @@ abstract class CollaborationJsonSpec extends AbstractJsonEndpointSpec
|
||||
case "cursor" -> "Opaque nextCursor returned by the previous page; omit for the first page.";
|
||||
case "before" -> "Timeline continuation token; alias for cursor. Supply only one.";
|
||||
case "includeDone" -> "Include completed and cancelled tasks; default false.";
|
||||
case "perConnection" -> "Also return one viewer per live connection (lease) in leases; default false. Since collaboration 1.2.0.";
|
||||
case "status" -> "Task status: OPEN (default), DONE or CANCELLED.";
|
||||
case "types" -> "Comma-separated AUDIT, ACTIVITY or activity type names (for example COMMENTED).";
|
||||
case "q" -> "Text to search in display names or vocabulary.";
|
||||
default -> name;
|
||||
};
|
||||
return (new Parameter().withName(name).withIn(location).withRequired(required).withDescription(description)
|
||||
.withSchema(new Schema().withType(name.equals("limit") ? Type.INTEGER : name.equals("includeDone") ? Type.BOOLEAN : Type.STRING)));
|
||||
.withSchema(new Schema().withType(name.equals("limit") ? Type.INTEGER : name.equals("includeDone") || name.equals("perConnection") ? Type.BOOLEAN : Type.STRING)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+26
-6
@@ -24,6 +24,7 @@ package com.kingsrook.qbits.collaboration.api;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
|
||||
import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
@@ -80,10 +81,22 @@ public final class CollaborationPresenceSchemas
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The snapshot is also the data shape of presence.changed events.
|
||||
** The snapshot is also the data shape of presence.changed events. Viewers are
|
||||
** included even when empty, whatever inclusion the serializer defaults to.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceSnapshotV1(String table, String pk, List<PresenceRegistry.Viewer> viewers) implements ToSchema
|
||||
public record CollaborationPresenceSnapshotV1(String table, String pk, @JsonInclude(JsonInclude.Include.ALWAYS) List<PresenceRegistry.Viewer> viewers,
|
||||
Integer leaseCount, @JsonInclude(JsonInclude.Include.NON_NULL) List<PresenceRegistry.Viewer> leases) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The merged snapshot without per-connection detail.
|
||||
*******************************************************************************/
|
||||
public CollaborationPresenceSnapshotV1(String table, String pk, List<PresenceRegistry.Viewer> viewers)
|
||||
{
|
||||
this(table, pk, viewers, null, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table snapshot deliberately excludes record ids and focused field names.
|
||||
*******************************************************************************/
|
||||
@@ -93,14 +106,17 @@ public final class CollaborationPresenceSchemas
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Live viewers, merged across tabs; presence.changed uses this payload.")
|
||||
.withProperties(new TreeMap<>(Map.of(
|
||||
"table", text("Parent table name.", false), "pk", text("Record key; absent for table events.", true),
|
||||
"viewers", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationPresenceViewerV1"))))));
|
||||
"viewers", new Schema().withType(Type.ARRAY).withDescription("Live viewers; always present, empty once the last viewer leaves.").withNullable(false).withItems(new Schema().withRefToSchema("CollaborationPresenceViewerV1")),
|
||||
"leaseCount", new Schema().withType(Type.INTEGER).withDescription("Live connections (leases) behind viewers; a user with two terminals counts twice. Since 1.2.0.").withNullable(true),
|
||||
"leases", new Schema().withType(Type.ARRAY).withDescription("One viewer per live connection, with connectionId and since. On GET only with perConnection=true; always on presence.changed. Since 1.2.0.")
|
||||
.withNullable(true).withItems(new Schema().withRefToSchema("CollaborationPresenceViewerV1"))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only public identity and focus are carried, never field contents.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceViewerV1(String userId, String displayName, String mode, String fieldName) implements ToSchema
|
||||
public record CollaborationPresenceViewerV1(String userId, String displayName, String mode, String fieldName, Integer connections, String connectionId, String sessionId, String since) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Editing takes precedence when one user has several open tabs.
|
||||
@@ -108,10 +124,14 @@ public final class CollaborationPresenceSchemas
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("One user merged across live tabs; EDITING takes precedence.")
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("One user merged across live tabs (EDITING takes precedence), or one live connection in leases.")
|
||||
.withProperties(new TreeMap<>(Map.of(
|
||||
"userId", text("Stable user identity.", false), "displayName", text("Session display name.", false),
|
||||
"mode", text("VIEWING or EDITING.", false).withEnumValues(List.of("VIEWING", "EDITING")), "fieldName", text("Focused field; null on table snapshots.", true)))));
|
||||
"mode", text("VIEWING or EDITING.", false).withEnumValues(List.of("VIEWING", "EDITING")), "fieldName", text("Focused field; null on table snapshots.", true),
|
||||
"connections", new Schema().withType(Type.INTEGER).withDescription("Live connections (tabs, terminals) merged into this viewer; 1 in leases. Since 1.2.0.").withNullable(true),
|
||||
"connectionId", text("Opaque per-connection id, only in leases: c + the first 16 hex digits of SHA-256(userId + newline + connectionId). Since 1.2.0.", true),
|
||||
"sessionId", text("Opaque id of the signed-in session behind this connection, only in leases (HMAC of the server session; never the session itself). Since 1.2.0.", true),
|
||||
"since", text("When this connection's lease started (ISO-8601), only in leases. Since 1.2.0.", true).withFormat("date-time")))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -514,7 +514,7 @@ public final class CollaborationSchemas
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Standard QQQ error response.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Deprecated since 1.1.0: errors use BasicErrorResponseV1.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("error", new Schema().withType(Type.STRING).withDescription("Human-readable reason the operation failed.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
@@ -714,6 +714,10 @@ public final class CollaborationSchemas
|
||||
schemas.put("CollaborationCommentPostRequestV1", new CollaborationCommentPostRequestV1(null, null, null).toSchema());
|
||||
schemas.put("CollaborationCommentEditRequestV1", new CollaborationCommentEditRequestV1(null).toSchema());
|
||||
schemas.put("CollaborationFollowRequestV1", new CollaborationFollowRequestV1(null, null).toSchema());
|
||||
schemas.put("CollaborationPresenceRequestV1", new CollaborationPresenceSchemas.CollaborationPresenceRequestV1(null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationPresenceLeaveV1", new CollaborationPresenceSchemas.CollaborationPresenceLeaveV1(null, null, null).toSchema());
|
||||
schemas.put("CollaborationPresenceSnapshotV1", new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(null, null, null).toSchema());
|
||||
schemas.put("CollaborationPresenceViewerV1", new CollaborationPresenceSchemas.CollaborationPresenceViewerV1(null, null, null, null, null, null, null, null).toSchema());
|
||||
schemas.putAll(CollaborationWorkSchemas.components());
|
||||
return (schemas);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.kingsrook.qbits.collaboration.api;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceService;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
@@ -48,7 +49,7 @@ public class GetPresenceSpec extends CollaborationJsonSpec
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("table!", "pk"));
|
||||
return (parameters("table!", "pk", "perConnection"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -68,6 +69,14 @@ public class GetPresenceSpec extends CollaborationJsonSpec
|
||||
{
|
||||
String table = required(input.getQueryParam("table"), "table");
|
||||
String pk = input.getQueryParam("pk");
|
||||
return (new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(table, pk, new PresenceService().snapshot(table, pk)));
|
||||
String perConnection = input.getQueryParam("perConnection");
|
||||
if(perConnection != null && !perConnection.equalsIgnoreCase("true") && !perConnection.equalsIgnoreCase("false"))
|
||||
{
|
||||
throw (new QBadRequestException("perConnection must be true or false"));
|
||||
}
|
||||
var service = new PresenceService();
|
||||
var viewers = service.snapshot(table, pk);
|
||||
var leases = service.connections(table, pk);
|
||||
return (new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(table, pk, viewers, leases.size(), "true".equalsIgnoreCase(perConnection) ? leases : null));
|
||||
}
|
||||
}
|
||||
@@ -267,15 +267,19 @@ public final class CollaborationHome
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
if(admin)
|
||||
{
|
||||
PresenceRegistry.get(QContext.getQInstance()).counts().forEach((table, viewers) ->
|
||||
var registry = PresenceRegistry.get(QContext.getQInstance());
|
||||
Map<String, Integer> connections = registry.connectionCounts();
|
||||
registry.counts().forEach((table, viewers) ->
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("table", tableLabel(table));
|
||||
row.put("viewers", viewers);
|
||||
row.put("connections", connections.getOrDefault(table, viewers));
|
||||
rows.add(row);
|
||||
});
|
||||
}
|
||||
return (table("Who's here", admin ? NOBODY_HERE : PRESENCE_ADMIN_ONLY, rows, column("Table", "table", "left"), column("People", "viewers", "right")));
|
||||
return (table("Who's here", admin ? NOBODY_HERE : PRESENCE_ADMIN_ONLY, rows, column("Table", "table", "left"), column("People", "viewers", "right"),
|
||||
column("Connections", "connections", "right")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -21,18 +21,33 @@
|
||||
|
||||
package com.kingsrook.qbits.collaboration.presence;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.IOException;
|
||||
import java.io.Serializable;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.TreeMap;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.realtime.RealtimeBus;
|
||||
@@ -49,6 +64,7 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
public static final long TTL_MILLIS = 45000;
|
||||
public static final int MAX_ENTRIES = 50000;
|
||||
private static final QLogger LOG = QLogger.getLogger(PresenceRegistry.class);
|
||||
private static final byte[] SESSION_SECRET = sessionSecret();
|
||||
private static final Map<QInstance, PresenceRegistry> REGISTRIES = new WeakHashMap<>();
|
||||
private final Map<Key, Entry> entries = new HashMap<>();
|
||||
private final RealtimeBusInterface bus;
|
||||
@@ -117,27 +133,52 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Each tab has its own lease; user views merge the live leases.
|
||||
** Each tab has its own lease; user views merge the live leases. The lease
|
||||
** keeps an opaque id of the server-side session (never the session uuid,
|
||||
** which is a bearer credential) and the time it started (renewals keep it).
|
||||
*******************************************************************************/
|
||||
private record Entry(String displayName, String mode, String fieldName, long at)
|
||||
private record Entry(String displayName, String mode, String fieldName, long at, String sessionId, long since)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Transport-independent snapshot entry, never a persisted directory row.
|
||||
** Merged per user, connections counts the live leases behind it; per
|
||||
** connection (connections = 1), connectionId is an opaque per-lease id,
|
||||
** sessionId an opaque id of the signed-in session (HMAC, never reversible to
|
||||
** the session uuid) and since the lease start (ISO-8601). None of the three
|
||||
** is set on merged viewers.
|
||||
*******************************************************************************/
|
||||
public record Viewer(String userId, String displayName, String mode, String fieldName) implements Serializable
|
||||
public record Viewer(String userId, String displayName, String mode, String fieldName, Integer connections, @JsonInclude(JsonInclude.Include.NON_NULL) String connectionId,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) String sessionId, @JsonInclude(JsonInclude.Include.NON_NULL) String since) implements Serializable
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A merged viewer behind a number of live leases.
|
||||
*******************************************************************************/
|
||||
public Viewer(String userId, String displayName, String mode, String fieldName, Integer connections)
|
||||
{
|
||||
this(userId, displayName, mode, fieldName, connections, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Refresh locally even if transport delivery fails; replicate every heartbeat.
|
||||
*******************************************************************************/
|
||||
public boolean heartbeat(String user, String displayName, String connection, String table, String pk, String mode, String fieldName)
|
||||
{
|
||||
return (heartbeat(user, displayName, connection, null, table, pk, mode, fieldName));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Renew a lease tied to the caller's server-side session. Only an opaque HMAC
|
||||
** of the session uuid is kept and replicated; the uuid itself is dropped here.
|
||||
*******************************************************************************/
|
||||
public boolean heartbeat(String user, String displayName, String connection, String sessionUuid, String table, String pk, String mode, String fieldName)
|
||||
{
|
||||
expire();
|
||||
var key = new Key(user, connection, table, pk);
|
||||
var entry = new Entry(displayName, mode, fieldName, clock.millis());
|
||||
long now = clock.millis();
|
||||
var entry = new Entry(displayName, mode, fieldName, now, opaqueSessionId(sessionUuid), now);
|
||||
if(!apply(key, entry, false))
|
||||
{
|
||||
return (false);
|
||||
@@ -152,7 +193,8 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
public void leave(String user, String connection, String table, String pk)
|
||||
{
|
||||
var key = new Key(user, connection, table, pk);
|
||||
var entry = new Entry(null, null, null, clock.millis());
|
||||
long now = clock.millis();
|
||||
var entry = new Entry(null, null, null, now, null, now);
|
||||
apply(key, entry, true);
|
||||
publish(key, entry, true);
|
||||
}
|
||||
@@ -171,6 +213,7 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
data.put("mode", entry.mode());
|
||||
data.put("fieldName", entry.fieldName());
|
||||
data.put("at", entry.at());
|
||||
data.put("sessionId", entry.sessionId());
|
||||
data.put("leave", leave);
|
||||
bus.publish(new RealtimeMessage().withChannel(INTERNAL_CHANNEL).withEvent("presence.heartbeat").withData(data));
|
||||
}
|
||||
@@ -197,7 +240,7 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
return;
|
||||
}
|
||||
apply(new Key(user, connection, table, (String) data.get("pk")),
|
||||
new Entry((String) data.get("displayName"), (String) data.get("mode"), (String) data.get("fieldName"), receivedAt), Boolean.TRUE.equals(data.get("leave")));
|
||||
new Entry((String) data.get("displayName"), (String) data.get("mode"), (String) data.get("fieldName"), receivedAt, (String) data.get("sessionId"), receivedAt), Boolean.TRUE.equals(data.get("leave")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -218,16 +261,25 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
LOG.warn("Presence capacity reached; refusing a new target");
|
||||
return (false);
|
||||
}
|
||||
List<Viewer> before = snapshotInternal(key.table(), key.pk());
|
||||
List<Viewer> before = projection(key.table(), key.pk());
|
||||
if(leave)
|
||||
{
|
||||
entries.remove(key);
|
||||
}
|
||||
else
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////
|
||||
// a renewal keeps the lease start (pods may differ by a little: //
|
||||
// the earliest start wins) and the first known session id //
|
||||
/////////////////////////////////////////////////////////////////
|
||||
if(old != null)
|
||||
{
|
||||
entry = new Entry(entry.displayName(), entry.mode(), entry.fieldName(), entry.at(),
|
||||
old.sessionId() != null ? old.sessionId() : entry.sessionId(), Math.min(old.since(), entry.since()));
|
||||
}
|
||||
entries.put(key, entry);
|
||||
}
|
||||
changed = !before.equals(snapshotInternal(key.table(), key.pk()));
|
||||
changed = !before.equals(projection(key.table(), key.pk()));
|
||||
}
|
||||
if(changed)
|
||||
{
|
||||
@@ -274,16 +326,137 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
*******************************************************************************/
|
||||
private List<Viewer> snapshotInternal(String table, String pk)
|
||||
{
|
||||
Map<String, Entry> users = new java.util.TreeMap<>();
|
||||
Map<String, Entry> users = new TreeMap<>();
|
||||
Map<String, Integer> connections = new TreeMap<>();
|
||||
for(var row : live(table, pk))
|
||||
{
|
||||
users.merge(row.getKey().user(), row.getValue(), (old, next) ->
|
||||
(!Objects.equals(old.mode(), next.mode()) ? "EDITING".equals(next.mode()) : next.at() >= old.at()) ? next : old);
|
||||
connections.merge(row.getKey().user(), 1, Integer::sum);
|
||||
}
|
||||
return (users.entrySet().stream().map(row -> new Viewer(row.getKey(), row.getValue().displayName(), row.getValue().mode(),
|
||||
pk == null ? null : row.getValue().fieldName(), connections.get(row.getKey()))).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One viewer per live lease, ordered by user, lease start and opaque id. A
|
||||
** table target counts each connection once per record it holds a lease on.
|
||||
*******************************************************************************/
|
||||
private List<Viewer> connectionsInternal(String table, String pk)
|
||||
{
|
||||
return (live(table, pk).stream()
|
||||
.map(row -> new Viewer(row.getKey().user(), row.getValue().displayName(), row.getValue().mode(), pk == null ? null : row.getValue().fieldName(), 1,
|
||||
opaqueConnectionId(row.getKey().user(), row.getKey().connection()), row.getValue().sessionId(), Instant.ofEpochMilli(row.getValue().since()).toString()))
|
||||
.sorted(java.util.Comparator.comparing(Viewer::userId).thenComparing(Viewer::since).thenComparing(Viewer::connectionId))
|
||||
.toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Live leases on a table (any record when pk is null) or on one record.
|
||||
*******************************************************************************/
|
||||
private List<Map.Entry<Key, Entry>> live(String table, String pk)
|
||||
{
|
||||
List<Map.Entry<Key, Entry>> result = new ArrayList<>();
|
||||
for(var row : entries.entrySet())
|
||||
{
|
||||
if(row.getKey().table().equals(table) && (pk == null || Objects.equals(row.getKey().pk(), pk)) && row.getValue().at() + TTL_MILLIS > clock.millis())
|
||||
{
|
||||
users.merge(row.getKey().user(), row.getValue(), (old, next) ->
|
||||
(!Objects.equals(old.mode(), next.mode()) ? "EDITING".equals(next.mode()) : next.at() >= old.at()) ? next : old);
|
||||
result.add(row);
|
||||
}
|
||||
}
|
||||
return (users.entrySet().stream().map(row -> new Viewer(row.getKey(), row.getValue().displayName(), row.getValue().mode(), pk == null ? null : row.getValue().fieldName())).toList());
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** What clients can observe: merged viewers and per-connection viewers. Lease
|
||||
** renewals that change neither generate no event.
|
||||
*******************************************************************************/
|
||||
private List<Viewer> projection(String table, String pk)
|
||||
{
|
||||
List<Viewer> result = new ArrayList<>(snapshotInternal(table, pk));
|
||||
result.addAll(connectionsInternal(table, pk));
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Opaque, stable per-lease id: "c" and the first 16 hex digits of
|
||||
** SHA-256(userId + "\n" + connectionId). A client can recognise its own
|
||||
** connection; nobody learns another client's connection id.
|
||||
*******************************************************************************/
|
||||
public static String opaqueConnectionId(String userId, String connectionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest((userId + "\n" + connectionId).getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder result = new StringBuilder("c");
|
||||
for(int i = 0; i < 8; i++)
|
||||
{
|
||||
result.append(String.format("%02x", digest[i]));
|
||||
}
|
||||
return (result.toString());
|
||||
}
|
||||
catch(NoSuchAlgorithmException e)
|
||||
{
|
||||
throw (new IllegalStateException("SHA-256 is unavailable", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Opaque session id: "s" and the first 16 hex digits of
|
||||
** HMAC-SHA256(secret, sessionUuid), or null without a session. The secret is
|
||||
** QQQ_PRESENCE_SESSION_SECRET (system property or environment; share it across
|
||||
** pods for ids that agree between pods) or else random per process. Session
|
||||
** uuids are bearer credentials and must never be stored or published.
|
||||
*******************************************************************************/
|
||||
public static String opaqueSessionId(String sessionUuid)
|
||||
{
|
||||
if(sessionUuid == null || sessionUuid.isBlank())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(SESSION_SECRET, "HmacSHA256"));
|
||||
byte[] digest = mac.doFinal(sessionUuid.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder result = new StringBuilder("s");
|
||||
for(int i = 0; i < 8; i++)
|
||||
{
|
||||
result.append(String.format("%02x", digest[i]));
|
||||
}
|
||||
return (result.toString());
|
||||
}
|
||||
catch(NoSuchAlgorithmException | InvalidKeyException e)
|
||||
{
|
||||
throw (new IllegalStateException("HmacSHA256 is unavailable", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configured shared secret, or 32 random bytes for this process.
|
||||
*******************************************************************************/
|
||||
private static byte[] sessionSecret()
|
||||
{
|
||||
String configured = System.getProperty("QQQ_PRESENCE_SESSION_SECRET", System.getenv("QQQ_PRESENCE_SESSION_SECRET"));
|
||||
if(configured != null && !configured.isBlank())
|
||||
{
|
||||
return (configured.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
byte[] random = new byte[32];
|
||||
new SecureRandom().nextBytes(random);
|
||||
return (random);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One viewer per live lease (opt-in per-connection presence, #54).
|
||||
*******************************************************************************/
|
||||
public List<Viewer> connections(String table, String pk)
|
||||
{
|
||||
expire();
|
||||
synchronized(this)
|
||||
{
|
||||
return (connectionsInternal(table, pk));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -294,12 +467,27 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
expire();
|
||||
synchronized(this)
|
||||
{
|
||||
Map<String, Integer> result = new java.util.TreeMap<>();
|
||||
Map<String, Integer> result = new TreeMap<>();
|
||||
entries.keySet().stream().map(Key::table).distinct().forEach(table -> result.put(table, snapshotInternal(table, null).size()));
|
||||
return (result);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Live lease (connection) counts per table, next to the user counts above.
|
||||
*******************************************************************************/
|
||||
public Map<String, Integer> connectionCounts()
|
||||
{
|
||||
expire();
|
||||
synchronized(this)
|
||||
{
|
||||
Map<String, Integer> result = new TreeMap<>();
|
||||
entries.keySet().stream().map(Key::table).distinct().forEach(table -> result.put(table, live(table, null).size()));
|
||||
result.values().removeIf(count -> count == 0);
|
||||
return (result);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record focus stays on its record channel; table events contain table snapshots.
|
||||
*******************************************************************************/
|
||||
@@ -323,23 +511,97 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
{
|
||||
data.put("pk", pk);
|
||||
}
|
||||
ArrayList<LinkedHashMap<String, Serializable>> viewers = new ArrayList<>();
|
||||
Viewers viewers = new Viewers();
|
||||
Viewers leases = new Viewers();
|
||||
synchronized(this)
|
||||
{
|
||||
for(Viewer viewer : snapshotInternal(table, pk))
|
||||
{
|
||||
LinkedHashMap<String, Serializable> value = new LinkedHashMap<>();
|
||||
value.put("userId", viewer.userId());
|
||||
value.put("displayName", viewer.displayName());
|
||||
value.put("mode", viewer.mode());
|
||||
value.put("fieldName", viewer.fieldName());
|
||||
viewers.add(value);
|
||||
}
|
||||
snapshotInternal(table, pk).forEach(viewer -> viewers.add(toMap(viewer)));
|
||||
connectionsInternal(table, pk).forEach(viewer -> leases.add(toMap(viewer)));
|
||||
}
|
||||
data.put("viewers", viewers);
|
||||
data.put("leaseCount", leases.size());
|
||||
data.put("leases", leases);
|
||||
bus.publish(new RealtimeMessage().withChannel(channel).withEvent("presence.changed").withData(data));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Event shape of a viewer; per-connection fields only when set.
|
||||
*******************************************************************************/
|
||||
private static LinkedHashMap<String, Serializable> toMap(Viewer viewer)
|
||||
{
|
||||
LinkedHashMap<String, Serializable> value = new LinkedHashMap<>();
|
||||
value.put("userId", viewer.userId());
|
||||
value.put("displayName", viewer.displayName());
|
||||
value.put("mode", viewer.mode());
|
||||
value.put("fieldName", viewer.fieldName());
|
||||
value.put("connections", viewer.connections());
|
||||
if(viewer.connectionId() != null)
|
||||
{
|
||||
value.put("connectionId", viewer.connectionId());
|
||||
if(viewer.sessionId() != null)
|
||||
{
|
||||
value.put("sessionId", viewer.sessionId());
|
||||
}
|
||||
value.put("since", viewer.since());
|
||||
}
|
||||
return (value);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Event viewers list that is never omitted: the SSE writer serializes event data
|
||||
** with NON_EMPTY inclusion, which would drop an empty list, but the schema
|
||||
** declares viewers a required array, so the last leave must send [] (#52).
|
||||
*******************************************************************************/
|
||||
@JsonSerialize(using = ViewersSerializer.class)
|
||||
public static final class Viewers extends ArrayList<LinkedHashMap<String, Serializable>>
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Writes each viewer with the caller's mapper settings and reports never empty.
|
||||
*******************************************************************************/
|
||||
public static final class ViewersSerializer extends StdSerializer<Viewers>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Jackson instantiates the serializer reflectively.
|
||||
*******************************************************************************/
|
||||
public ViewersSerializer()
|
||||
{
|
||||
super(Viewers.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Always an array, including the empty array after the last viewer leaves.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void serialize(Viewers viewers, JsonGenerator generator, SerializerProvider provider) throws IOException
|
||||
{
|
||||
generator.writeStartArray(viewers, viewers.size());
|
||||
for(LinkedHashMap<String, Serializable> viewer : viewers)
|
||||
{
|
||||
provider.defaultSerializeValue(viewer, generator);
|
||||
}
|
||||
generator.writeEndArray();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** NON_EMPTY inclusion consults this; an empty viewer list is still a value.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isEmpty(SerializerProvider provider, Viewers viewers)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Release listeners and timer on host shutdown or in tests.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -62,7 +62,7 @@ public class PresenceService
|
||||
throw (new QBadRequestException("Unknown focused field"));
|
||||
}
|
||||
String name = QContext.getQSession().getUser().getFullName();
|
||||
if(!registry().heartbeat(user, name == null || name.isBlank() ? user : name, connection, table, pk, mode, field))
|
||||
if(!registry().heartbeat(user, name == null || name.isBlank() ? user : name, connection, QContext.getQSession().getUuid(), table, pk, mode, field))
|
||||
{
|
||||
throw (new QBadRequestException("Presence capacity reached; retry later"));
|
||||
}
|
||||
@@ -89,6 +89,15 @@ public class PresenceService
|
||||
return (registry().snapshot(table, authorize(table, pk)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One viewer per live lease, behind the same access boundary as snapshot.
|
||||
*******************************************************************************/
|
||||
public List<PresenceRegistry.Viewer> connections(String table, String pk) throws QException
|
||||
{
|
||||
PersonalRecords.currentUserId();
|
||||
return (registry().connections(table, authorize(table, pk)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A refusal always has the same not-found result, including disabled features.
|
||||
*******************************************************************************/
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
version=${project.version}
|
||||
@@ -29,6 +29,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
|
||||
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -56,7 +57,7 @@ class CollaborationApiContractTest
|
||||
assertThat(UiApiContract.check(generated, Files.readString(path), null, 1,
|
||||
"mvnw.cmd -o -q -Dtest=CollaborationApiContractTest -Dcollaboration.openapi.update=true test")).isEmpty();
|
||||
var model = version.generateExtensionOpenAPIModel("qqq", extension);
|
||||
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Collaboration"));
|
||||
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Collaboration") || name.equals(BasicErrorResponseV1.class.getSimpleName()));
|
||||
assertThat(model.getPaths().keySet()).allMatch(pathName -> pathName.startsWith("/qqq/v1/qbit/collaboration/"));
|
||||
assertThat(generated).contains("multipart/form-data", "format: \"binary\"", "clients follow url", "nextCursor");
|
||||
assertThat(extension.getSpecs()).hasSize(45);
|
||||
|
||||
@@ -37,6 +37,7 @@ import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.kingsrook.qbits.collaboration.attachments.AttachmentStorage;
|
||||
import com.kingsrook.qbits.objectstorage.api.PresignGet;
|
||||
import com.kingsrook.qbits.objectstorage.api.PutRequest;
|
||||
@@ -222,6 +223,21 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
|
||||
assertThat(ok("POST", "/presence", body).get("viewers")).hasSize(1);
|
||||
assertThat(ok("GET", "/presence?table=document&pk=1", null).at("/viewers/0/mode").asText()).isEqualTo("EDITING");
|
||||
assertThat(ok("GET", "/presence?table=document", null).at("/viewers/0/fieldName").isNull()).isTrue();
|
||||
ok("POST", "/presence", body.replace("tab-one", "tab-two").replace("EDITING", "VIEWING"));
|
||||
JsonNode merged = ok("GET", "/presence?table=document&pk=1", null);
|
||||
assertThat(merged.get("viewers")).hasSize(1);
|
||||
assertThat(merged.at("/viewers/0/connections").asInt()).isEqualTo(2);
|
||||
assertThat(merged.get("leaseCount").asInt()).isEqualTo(2);
|
||||
assertThat(merged.has("leases")).isFalse();
|
||||
JsonNode perConnection = ok("GET", "/presence?table=document&pk=1&perConnection=true", null);
|
||||
assertThat(perConnection.get("viewers")).hasSize(1);
|
||||
assertThat(perConnection.get("leases")).hasSize(2);
|
||||
assertThat(perConnection.at("/leases/0/connectionId").asText()).startsWith("c").doesNotContain("tab-");
|
||||
assertThat(perConnection.at("/leases/0/since").asText()).isNotBlank();
|
||||
assertThat(perConnection.at("/leases/0/sessionId").asText()).startsWith("s");
|
||||
assertThat(perConnection.toString()).doesNotContain("alice" + sessionSuffix).doesNotContain(sessionSuffix);
|
||||
assertError(400, "GET", "/presence?table=document&perConnection=maybe", null, "alice");
|
||||
ok("DELETE", "/presence", "{\"connectionId\":\"tab-two\",\"table\":\"document\",\"pk\":\"1\"}");
|
||||
assertError(404, "GET", "/presence?table=document&pk=3", null, "alice");
|
||||
assertError(404, "POST", "/presence", body, "outsider");
|
||||
assertError(400, "POST", "/presence", "{\"connectionId\":\"x\",\"table\":\"document\",\"mode\":\"BAD\"}", "alice");
|
||||
@@ -431,8 +447,16 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
|
||||
HttpResponse<String> hidden = request("GET", "/records/document/3/" + feature, null, "alice");
|
||||
HttpResponse<String> absent = request("GET", "/records/document/999/" + feature, null, "alice");
|
||||
assertThat(hidden.statusCode()).isEqualTo(404);
|
||||
assertThat(hidden.body()).isEqualTo(absent.body());
|
||||
assertThat(json(hidden, 404).get("error").asText()).isEqualTo("Not found");
|
||||
ObjectNode hiddenError = (ObjectNode) json(hidden, 404);
|
||||
ObjectNode absentError = (ObjectNode) json(absent, 404);
|
||||
assertThat(hiddenError.path("traceId").asText()).isNotBlank();
|
||||
assertThat(absentError.path("traceId").asText()).isNotBlank();
|
||||
hiddenError.remove("traceId");
|
||||
absentError.remove("traceId");
|
||||
assertThat(hiddenError).isEqualTo(absentError);
|
||||
assertThat(hiddenError.path("error").asText()).isEqualTo("Not found");
|
||||
assertThat(hiddenError.path("code").asText()).isEqualTo("NOT_FOUND");
|
||||
assertThat(hiddenError.path("message").asText()).isEqualTo("Not found");
|
||||
}
|
||||
assertError(404, "POST", "/records/document/3/comments", "not json", "alice");
|
||||
assertError(404, "GET", "/mentions/candidates?table=document&pk=3", null, "alice");
|
||||
@@ -500,15 +524,19 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
|
||||
JsonNode version = json(client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/qqq/v1/version")).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofString()), 200);
|
||||
assertThat(version.at("/extensions/0/name").asText()).isEqualTo("collaboration");
|
||||
assertThat(version.at("/extensions/0/version").asText()).isEqualTo("1.0.0");
|
||||
assertThat(version.at("/extensions/0/version").asText()).isEqualTo("1.2.0");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Framework errors always contain a human-readable error property.
|
||||
** Framework errors include the standard message, code and request trace.
|
||||
*******************************************************************************/
|
||||
private void assertError(int status, String method, String path, String body, String caller) throws Exception
|
||||
{
|
||||
assertThat(json(request(method, path, body, caller), status).get("error").asText()).isNotBlank();
|
||||
var error = json(request(method, path, body, caller), status);
|
||||
assertThat(error.path("error").asText()).isNotBlank();
|
||||
assertThat(error.path("code").asText()).isNotBlank();
|
||||
assertThat(error.path("message").asText()).isNotBlank();
|
||||
assertThat(error.path("traceId").asText()).isNotBlank();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+1
-1
@@ -146,7 +146,7 @@ class CollaborationTablesGuardrailTest extends PersonalServiceFixture
|
||||
assertThat(HelpBundles.of(instance).getBundles()).singleElement().satisfies(bundle ->
|
||||
{
|
||||
assertThat(bundle.getId()).isEqualTo("qbit-collaboration");
|
||||
assertThat(bundle.getVersion()).as("the help bundle carries the artifact version").isEqualTo(System.getProperty("qbit.artifactVersion"));
|
||||
assertThat(bundle.getVersion()).as("the help bundle carries the artifact version").isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(CollaborationQBitProducer.VERSION);
|
||||
assertThat(bundle.getEntries()).anyMatch(entry -> entry.getKey().equals("app:collaboration;slot:help"));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ class CollaborationTest
|
||||
assertThat(producer.isEnabled()).isTrue();
|
||||
assertThat(producer.getQBitConfig()).isSameAs(config);
|
||||
assertThat(producer.getNamespace()).isEqualTo("collaboration");
|
||||
assertThat(producer.getQBitMetaData().getVersion()).isEqualTo("0.1.0");
|
||||
assertThat(producer.getQBitMetaData().getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(CollaborationQBitProducer.VERSION);
|
||||
assertThat(producer.getQBitMetaData().getArtifactId()).isEqualTo("qbit-collaboration");
|
||||
assertThat(config.getDefaultBackendNameForTables()).isEqualTo("memory");
|
||||
assertThat(config.getAdminApp()).isTrue();
|
||||
|
||||
@@ -24,8 +24,10 @@ package com.kingsrook.qbits.collaboration;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import com.kingsrook.qbits.collaboration.api.CollaborationPresenceSchemas;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceService;
|
||||
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
|
||||
@@ -36,6 +38,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.realtime.InMemoryRealtimeBus;
|
||||
import com.kingsrook.qqq.backend.core.realtime.RealtimeMessage;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -61,7 +64,7 @@ class PresenceTest extends PersonalServiceFixture
|
||||
assertThat(second.snapshot("document", "1")).hasSize(1);
|
||||
clock.advance(1000);
|
||||
second.heartbeat("alice", "Alice", "two", "document", "1", "EDITING", "title");
|
||||
assertThat(first.snapshot("document", "1")).containsExactly(new PresenceRegistry.Viewer("alice", "Alice", "EDITING", "title"));
|
||||
assertThat(first.snapshot("document", "1")).containsExactly(new PresenceRegistry.Viewer("alice", "Alice", "EDITING", "title", 2));
|
||||
assertThat(first.snapshot("document", null).getFirst().fieldName()).isNull();
|
||||
int changes = (int) events.stream().filter(event -> event.getEvent().equals("presence.changed")).count();
|
||||
clock.advance(20000);
|
||||
@@ -87,6 +90,177 @@ class PresenceTest extends PersonalServiceFixture
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The last leave still sends viewers: the SSE writer and the HTTP snapshot must
|
||||
** both carry an empty array, never omit the field (#52).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void lastLeaveSendsEmptyViewers() throws Exception
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
try(var bus = new InMemoryRealtimeBus(); var registry = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES))
|
||||
{
|
||||
List<RealtimeMessage> events = new CopyOnWriteArrayList<>();
|
||||
bus.subscribe(events::add);
|
||||
registry.heartbeat("bob", "Bob", "one", "person", null, "VIEWING", null);
|
||||
registry.heartbeat("bob", "Bob", "one", "person", "7", "EDITING", "name");
|
||||
registry.leave("bob", "one", "person", null);
|
||||
registry.leave("bob", "one", "person", "7");
|
||||
assertThat(registry.snapshot("person", null)).isEmpty();
|
||||
|
||||
for(String channel : List.of("table:person", "record:person:7"))
|
||||
{
|
||||
RealtimeMessage last = events.stream().filter(event -> event.getChannel().equals(channel) && event.getEvent().equals("presence.changed")).reduce((first, second) -> second).orElseThrow();
|
||||
assertThat(last.getData()).containsKey("viewers");
|
||||
assertThat((List<?>) last.getData().get("viewers")).isEmpty();
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// the realtime hub copies the data map and writes it with toJson, //
|
||||
// whose NON_EMPTY inclusion used to drop the empty viewers list //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
String json = JsonUtils.toJson(new LinkedHashMap<>(last.getData()));
|
||||
assertThat(JsonUtils.toJSONObject(json).getJSONArray("viewers").length()).isZero();
|
||||
}
|
||||
|
||||
String occupied = JsonUtils.toJson(events.stream().filter(event -> event.getChannel().equals("record:person:7")).findFirst().orElseThrow().getData());
|
||||
assertThat(JsonUtils.toJSONObject(occupied).getJSONArray("viewers").getJSONObject(0).getString("fieldName")).isEqualTo("name");
|
||||
|
||||
var snapshot = new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1("person", null, registry.snapshot("person", null));
|
||||
assertThat(JsonUtils.toJSONObject(JsonUtils.toJson(snapshot)).getJSONArray("viewers").length()).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Two leases of one user merge into one viewer with connections = 2; per
|
||||
** connection they are two viewers with opaque ids, modes, fields and starts.
|
||||
** Counts keep counting users; connectionCounts counts leases (#54).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void perConnectionViewersAndCounts() throws Exception
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
try(var bus = new InMemoryRealtimeBus(); var first = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES);
|
||||
var second = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES))
|
||||
{
|
||||
first.heartbeat("admin", "Admin", "terminal-1", "session-a", "person", "7", "VIEWING", null);
|
||||
clock.advance(1000);
|
||||
second.heartbeat("admin", "Admin", "terminal-2", "session-b", "person", "7", "EDITING", "name");
|
||||
first.heartbeat("bob", "Bob", "tab", "session-c", "person", "7", "VIEWING", null);
|
||||
|
||||
assertThat(first.snapshot("person", "7")).containsExactly(
|
||||
new PresenceRegistry.Viewer("admin", "Admin", "EDITING", "name", 2),
|
||||
new PresenceRegistry.Viewer("bob", "Bob", "VIEWING", null, 1));
|
||||
|
||||
List<PresenceRegistry.Viewer> connections = second.connections("person", "7");
|
||||
assertThat(connections).hasSize(3);
|
||||
assertThat(connections.subList(0, 2)).extracting(PresenceRegistry.Viewer::userId, PresenceRegistry.Viewer::mode, PresenceRegistry.Viewer::fieldName, PresenceRegistry.Viewer::connections)
|
||||
.containsExactly(org.assertj.core.groups.Tuple.tuple("admin", "VIEWING", null, 1), org.assertj.core.groups.Tuple.tuple("admin", "EDITING", "name", 1));
|
||||
assertThat(connections.getFirst().connectionId()).isEqualTo(PresenceRegistry.opaqueConnectionId("admin", "terminal-1")).startsWith("c").hasSize(17);
|
||||
assertThat(connections.getFirst().since()).isEqualTo("2026-10-13T12:00:00Z");
|
||||
assertThat(connections.get(1).since()).isEqualTo("2026-10-13T12:00:01Z");
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// renewals keep the lease start; the session uuid replicates //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
clock.advance(20000);
|
||||
first.heartbeat("admin", "Admin", "terminal-1", "session-a", "person", "7", "VIEWING", null);
|
||||
assertThat(second.connections("person", "7").getFirst().since()).isEqualTo("2026-10-13T12:00:00Z");
|
||||
assertThat(second.connections("person", "7")).extracting(PresenceRegistry.Viewer::sessionId)
|
||||
.containsExactly(PresenceRegistry.opaqueSessionId("session-a"), PresenceRegistry.opaqueSessionId("session-b"), PresenceRegistry.opaqueSessionId("session-c"));
|
||||
assertThat(PresenceRegistry.opaqueSessionId("session-a")).startsWith("s").hasSize(17).isNotEqualTo(PresenceRegistry.opaqueSessionId("session-b"));
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// table snapshots and counts: users vs leases //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
assertThat(first.snapshot("person", null)).extracting(PresenceRegistry.Viewer::connections).containsExactly(2, 1);
|
||||
assertThat(first.connections("person", null)).extracting(PresenceRegistry.Viewer::fieldName).containsOnlyNulls();
|
||||
assertThat(first.counts()).containsExactly(java.util.Map.entry("person", 2));
|
||||
assertThat(first.connectionCounts()).containsExactly(java.util.Map.entry("person", 3));
|
||||
|
||||
second.leave("admin", "terminal-2", "person", "7");
|
||||
assertThat(first.snapshot("person", "7").getFirst()).isEqualTo(new PresenceRegistry.Viewer("admin", "Admin", "VIEWING", null, 1));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Session uuids are bearer credentials: no bus message (internal replication
|
||||
** or client event) and no snapshot carries one, only the opaque session id.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rawSessionUuidIsNeverPublished() throws Exception
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
String sessionUuid = "7d1c2a9e-secret-session-uuid";
|
||||
try(var bus = new InMemoryRealtimeBus(); var first = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES);
|
||||
var second = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES))
|
||||
{
|
||||
List<RealtimeMessage> events = new CopyOnWriteArrayList<>();
|
||||
bus.subscribe(events::add);
|
||||
first.heartbeat("admin", "Admin", "terminal-1", sessionUuid, "person", "7", "VIEWING", null);
|
||||
first.heartbeat("admin", "Admin", "terminal-1", sessionUuid, "person", "7", "EDITING", "name");
|
||||
first.leave("admin", "terminal-1", "person", "7");
|
||||
first.heartbeat("admin", "Admin", "terminal-1", sessionUuid, "person", null, "VIEWING", null);
|
||||
assertThat(events).isNotEmpty().anySatisfy(event -> assertThat(event.getChannel()).isEqualTo(PresenceRegistry.INTERNAL_CHANNEL));
|
||||
for(RealtimeMessage event : events)
|
||||
{
|
||||
assertThat(JsonUtils.toJson(new LinkedHashMap<>(event.getData()))).doesNotContain(sessionUuid);
|
||||
}
|
||||
assertThat(events).filteredOn(event -> event.getChannel().equals(PresenceRegistry.INTERNAL_CHANNEL) && !Boolean.TRUE.equals(event.getData().get("leave")))
|
||||
.allSatisfy(event -> assertThat(event.getData().get("sessionId")).isEqualTo(PresenceRegistry.opaqueSessionId(sessionUuid)));
|
||||
var snapshot = new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1("person", null, second.snapshot("person", null), 1, second.connections("person", null));
|
||||
String json = JsonUtils.toJson(snapshot);
|
||||
assertThat(json).doesNotContain(sessionUuid).contains(PresenceRegistry.opaqueSessionId(sessionUuid));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** presence.changed carries connections per merged viewer, a lease count and
|
||||
** one lease entry per connection; a second terminal of one user is a change.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void changedEventCarriesConnections() throws Exception
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
try(var bus = new InMemoryRealtimeBus(); var registry = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES))
|
||||
{
|
||||
List<RealtimeMessage> events = new CopyOnWriteArrayList<>();
|
||||
bus.subscribe(events::add);
|
||||
registry.heartbeat("admin", "Admin", "terminal-1", "person", "7", "VIEWING", null);
|
||||
int before = recordEvents(events).size();
|
||||
registry.heartbeat("admin", "Admin", "terminal-2", "person", "7", "VIEWING", null);
|
||||
List<RealtimeMessage> after = recordEvents(events);
|
||||
assertThat(after).hasSize(before + 1);
|
||||
|
||||
var json = JsonUtils.toJSONObject(JsonUtils.toJson(new LinkedHashMap<>(after.getLast().getData())));
|
||||
assertThat(json.getJSONArray("viewers").length()).isEqualTo(1);
|
||||
assertThat(json.getJSONArray("viewers").getJSONObject(0).getInt("connections")).isEqualTo(2);
|
||||
assertThat(json.getJSONArray("viewers").getJSONObject(0).has("connectionId")).isFalse();
|
||||
assertThat(json.getInt("leaseCount")).isEqualTo(2);
|
||||
assertThat(json.getJSONArray("leases").length()).isEqualTo(2);
|
||||
var lease = json.getJSONArray("leases").getJSONObject(1);
|
||||
assertThat(lease.getString("userId")).isEqualTo("admin");
|
||||
assertThat(lease.getString("mode")).isEqualTo("VIEWING");
|
||||
assertThat(lease.getInt("connections")).isEqualTo(1);
|
||||
assertThat(lease.getString("connectionId")).startsWith("c");
|
||||
assertThat(lease.getString("since")).isEqualTo("2026-10-13T12:00:00Z");
|
||||
assertThat(json.toString()).doesNotContain("terminal-");
|
||||
|
||||
registry.leave("admin", "terminal-1", "person", "7");
|
||||
registry.leave("admin", "terminal-2", "person", "7");
|
||||
var last = JsonUtils.toJSONObject(JsonUtils.toJson(new LinkedHashMap<>(recordEvents(events).getLast().getData())));
|
||||
assertThat(last.getInt("leaseCount")).isZero();
|
||||
assertThat(last.getJSONArray("leases").length()).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record-channel presence.changed events so far.
|
||||
*******************************************************************************/
|
||||
private static List<RealtimeMessage> recordEvents(List<RealtimeMessage> events)
|
||||
{
|
||||
return (events.stream().filter(event -> event.getChannel().equals("record:person:7") && event.getEvent().equals("presence.changed")).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** At capacity, existing leases renew while new targets are refused on both pods.
|
||||
*******************************************************************************/
|
||||
@@ -117,7 +291,15 @@ class PresenceTest extends PersonalServiceFixture
|
||||
void accessFeatureAndSnapshot() throws Exception
|
||||
{
|
||||
var service = new PresenceService();
|
||||
QContext.getQSession().setUuid("session-uuid-alice");
|
||||
assertThat(service.heartbeat("one", "document", "01", "EDITING", "title")).hasSize(1);
|
||||
assertThat(service.connections("document", "1")).singleElement().satisfies(viewer ->
|
||||
{
|
||||
assertThat(viewer.connectionId()).isEqualTo(PresenceRegistry.opaqueConnectionId("alice", "one"));
|
||||
assertThat(viewer.sessionId()).isEqualTo(PresenceRegistry.opaqueSessionId("session-uuid-alice"));
|
||||
assertThat(JsonUtils.toJson(new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1("document", "1", service.snapshot("document", "1"), 1, service.connections("document", "1"))))
|
||||
.doesNotContain("session-uuid-alice");
|
||||
});
|
||||
assertThat(service.snapshot("document", "1")).extracting(PresenceRegistry.Viewer::userId).containsExactly("alice");
|
||||
assertThat(service.snapshot("document", null)).hasSize(1);
|
||||
assertThatThrownBy(() -> service.heartbeat("one", "document", "3", "VIEWING", null)).isInstanceOf(QNotFoundException.class);
|
||||
@@ -137,6 +319,7 @@ class PresenceTest extends PersonalServiceFixture
|
||||
admin();
|
||||
assertThat(CollaborationHome.presence().getRows()).extracting(row -> row.get("table")).containsExactly("Documents");
|
||||
assertThat(CollaborationHome.presence().getRows().getFirst().get("viewers")).isEqualTo(1);
|
||||
assertThat(CollaborationHome.presence().getRows().getFirst().get("connections")).isEqualTo(1);
|
||||
login("alice");
|
||||
assertThat(CollaborationHome.presence().getRows()).isEmpty();
|
||||
assertThat(CollaborationHome.presence().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.PRESENCE_ADMIN_ONLY);
|
||||
|
||||
Reference in new issue
Block a user