Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
// and BuiltAppRoute schemas (^[a-z0-9][a-z0-9-]*[a-z0-9]$, min 2 max 48 chars).
['name' => 'applications#getManifest', 'url' => '/api/applications/{slug}/manifest', 'verb' => 'GET', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']],

// Versioning — diff endpoint (chain spec #6 openbuilt-versioning, REQ-OBV-005). Returns
// two ApplicationVersion manifest blobs in one round-trip so the client diff component
// does not double-fetch. `from`/`to` are ApplicationVersion UUIDs OR the literal `draft`.
// Specific route MUST precede the SPA catch-all (memory rule: Symfony specific-first).
['name' => 'applications#diffVersions', 'url' => '/api/applications/{slug}/versions/diff', 'verb' => 'GET', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']],

// SPA catch-all — same controller as the index route; must use a distinct route name
// (duplicate names replace the earlier route in Symfony, which breaks GET /).
['name' => 'dashboard#catchAll', 'url' => '/{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], 'defaults' => ['path' => '']],
Expand Down
38 changes: 31 additions & 7 deletions l10n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,13 @@
"Settings": "Settings",
"Settings saved successfully": "Settings saved successfully",
"Saving...": "Saving...",
"Saving…": "Saving…",
"This app needs OpenRegister to store and manage data. Please install OpenRegister from the app store to get started.": "This app needs OpenRegister to store and manage data. Please install OpenRegister from the app store to get started.",
"User settings will appear here in a future update.": "User settings will appear here in a future update.",
"Version Information": "Version Information",
"Information about the current OpenBuilt installation": "Information about the current OpenBuilt installation",
"Support": "Support",
"For support, contact us at": "For support, contact us at",

"Virtual apps": "Virtual apps",
"No virtual apps yet — seed `hello-world` should appear after install.": "No virtual apps yet — seed `hello-world` should appear after install.",
Expand All @@ -37,14 +42,33 @@
"Integrator-only editor: edit the raw JSON manifest below. The visual editor lives in a follow-on release (openbuilt-page-editor).": "Integrator-only editor: edit the raw JSON manifest below. The visual editor lives in a follow-on release (openbuilt-page-editor).",
"Paste or edit the JSON manifest here. See @conduction/nextcloud-vue/src/schemas/app-manifest.schema.json for the canonical schema.": "Paste or edit the JSON manifest here. See @conduction/nextcloud-vue/src/schemas/app-manifest.schema.json for the canonical schema.",
"Invalid manifest": "Invalid manifest",
"Saving…": "Saving…",
"Open virtual app": "Open virtual app",

"openbuilt.helloworld.menu.messages": "Messages",
"openbuilt.helloworld.title.messages": "Hello World — messages",
"openbuilt.helloworld.title.message": "Message",
"openbuilt.helloworld.title.create": "New message",
"openbuilt.editor.help": "Integrator-only editor: edit the raw JSON manifest. Visual editor lives in chain spec openbuilt-page-editor."
"Publish": "Publish",
"Publishing…": "Publishing…",
"draft": "draft",
"published": "published",
"archived": "archived",
"modified since last publish": "modified since last publish",
"Editor": "Editor",
"Version history": "Version history",
"Diff": "Diff",
"Published version {uuid}": "Published version {uuid}",
"No versions yet — publish this app to create the first snapshot.": "No versions yet — publish this app to create the first snapshot.",
"Roll back to this version": "Roll back to this version",
"Compare with current draft": "Compare with current draft",
"Published": "Published",
"By": "By",
"Loading…": "Loading…",
"Roll back to version {version}?": "Roll back to version {version}?",
"Rolling back copies this snapshot's manifest onto the current draft. Existing history is preserved (append-only).": "Rolling back copies this snapshot's manifest onto the current draft. Existing history is preserved (append-only).",
"Roll back": "Roll back",
"Cancel": "Cancel",
"Manifest diff": "Manifest diff",
"Nothing to diff — publish the app first.": "Nothing to diff — publish the app first.",
"From": "From",
"To": "To",
"Current draft": "Current draft",
"Loading diff…": "Loading diff…"
},
"plurals": ""
}
38 changes: 31 additions & 7 deletions l10n/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,13 @@
"Settings": "Instellingen",
"Settings saved successfully": "Instellingen succesvol opgeslagen",
"Saving...": "Opslaan...",
"Saving…": "Opslaan…",
"This app needs OpenRegister to store and manage data. Please install OpenRegister from the app store to get started.": "Deze app heeft OpenRegister nodig om gegevens op te slaan en te beheren. Installeer OpenRegister via de app store om te beginnen.",
"User settings will appear here in a future update.": "Gebruikersinstellingen verschijnen hier in een toekomstige update.",
"Version Information": "Versie-informatie",
"Information about the current OpenBuilt installation": "Informatie over de huidige OpenBuilt-installatie",
"Support": "Ondersteuning",
"For support, contact us at": "Voor ondersteuning, neem contact op via",

"Virtual apps": "Virtuele apps",
"No virtual apps yet — seed `hello-world` should appear after install.": "Nog geen virtuele apps — de `hello-world`-seed zou na installatie zichtbaar moeten zijn.",
Expand All @@ -37,14 +42,33 @@
"Integrator-only editor: edit the raw JSON manifest below. The visual editor lives in a follow-on release (openbuilt-page-editor).": "Editor voor integrators: bewerk hieronder het ruwe JSON-manifest. De visuele editor komt in een vervolg-release (openbuilt-page-editor).",
"Paste or edit the JSON manifest here. See @conduction/nextcloud-vue/src/schemas/app-manifest.schema.json for the canonical schema.": "Plak of bewerk hier het JSON-manifest. Zie @conduction/nextcloud-vue/src/schemas/app-manifest.schema.json voor het canonieke schema.",
"Invalid manifest": "Ongeldig manifest",
"Saving…": "Opslaan…",
"Open virtual app": "Open virtuele app",

"openbuilt.helloworld.menu.messages": "Berichten",
"openbuilt.helloworld.title.messages": "Hello World — berichten",
"openbuilt.helloworld.title.message": "Bericht",
"openbuilt.helloworld.title.create": "Nieuw bericht",
"openbuilt.editor.help": "Editor voor integrators: bewerk het ruwe JSON-manifest. De visuele editor komt in vervolgspec openbuilt-page-editor."
"Publish": "Publiceren",
"Publishing…": "Publiceren…",
"draft": "concept",
"published": "gepubliceerd",
"archived": "gearchiveerd",
"modified since last publish": "gewijzigd sinds laatste publicatie",
"Editor": "Editor",
"Version history": "Versiegeschiedenis",
"Diff": "Vergelijken",
"Published version {uuid}": "Versie {uuid} gepubliceerd",
"No versions yet — publish this app to create the first snapshot.": "Nog geen versies — publiceer deze app om de eerste snapshot te maken.",
"Roll back to this version": "Terugzetten naar deze versie",
"Compare with current draft": "Vergelijken met huidig concept",
"Published": "Gepubliceerd",
"By": "Door",
"Loading…": "Laden…",
"Roll back to version {version}?": "Terugzetten naar versie {version}?",
"Rolling back copies this snapshot's manifest onto the current draft. Existing history is preserved (append-only).": "Bij terugzetten wordt het manifest van deze snapshot op het huidige concept geplaatst. Bestaande geschiedenis blijft behouden (alleen toevoegen).",
"Roll back": "Terugzetten",
"Cancel": "Annuleren",
"Manifest diff": "Manifest-vergelijking",
"Nothing to diff — publish the app first.": "Niets te vergelijken — publiceer de app eerst.",
"From": "Van",
"To": "Naar",
"Current draft": "Huidig concept",
"Loading diff…": "Vergelijking laden…"
},
"plurals": ""
}
11 changes: 11 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@

namespace OCA\OpenBuilt\AppInfo;

use OCA\OpenBuilt\Listener\ApplicationVersionSnapshotListener;
use OCA\OpenBuilt\Listener\DeepLinkRegistrationListener;
use OCA\OpenRegister\Event\DeepLinkRegistrationEvent;
use OCA\OpenRegister\Event\ObjectTransitionedEvent;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
Expand Down Expand Up @@ -66,6 +68,15 @@ public function register(IRegistrationContext $context): void
listener: DeepLinkRegistrationListener::class
);

// Snapshot the Application's manifest into ApplicationVersion on
// draft→published transitions (chain spec #6 openbuilt-versioning,
// ADR-031 §Exceptions(1) — declarative-first fallback because OR's
// engine does not yet execute on_transition.create_relation).
$context->registerEventListener(
event: ObjectTransitionedEvent::class,
listener: ApplicationVersionSnapshotListener::class
);

// Repair steps (InitializeSettings + SeedHelloWorld) are declared in info.xml.
}//end register()

Expand Down
150 changes: 150 additions & 0 deletions lib/Controller/ApplicationsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,156 @@ public function getManifest(string $slug): JSONResponse
}//end try
}//end getManifest()

/**
* Return two manifest blobs side-by-side so the client diff component
* can render without a second round-trip (REQ-OBV-005, chain spec #6).
*
* Resolves `{slug}` to an Application via the BuiltAppRoute index,
* accepts the literal string `draft` for either `from`/`to` to mean
* "the current draft manifest on the Application", otherwise looks
* up both referenced ApplicationVersion rows. Returns a shape of
* `{ from: { manifest, version, publishedAt }, to: { manifest,
* version, publishedAt } }`. Per ADR-032 this is thin glue
* (~30 LOC of logic); no service class.
*
* @param string $slug The virtual-app slug from the URL
* @param string $from ApplicationVersion UUID or the literal `draft`
* @param string $to ApplicationVersion UUID or the literal `draft`
*
* @return JSONResponse Both blobs on 200, or a 404 envelope on miss
*
* IDOR-safe: slug → BuiltAppRoute lookup enforces org scope via OR's
* standard multitenancy (RegisterMapper::find + ObjectService::searchObjects),
* and the resolveVersionBlob() check on `applicationUuid` rejects snapshots
* that do not belong to this Application. Mirrors getManifest()'s pattern.
*/
#[NoAdminRequired]
#[NoCSRFRequired]
public function diffVersions(string $slug, string $from, string $to): JSONResponse
{
try {
$registerId = $this->registerMapper->find('openbuilt', _multitenancy: false)->getId();
$routeSchema = $this->schemaMapper->find('built-app-route', _multitenancy: false)->getId();

$routeResults = $this->objectService->searchObjects(
query: [
'@self' => [
'register' => $registerId,
'schema' => $routeSchema,
],
'slug' => $slug,
]
);

if (empty($routeResults) === true) {
return new JSONResponse(
data: ['error' => 'not_found', 'message' => 'No published virtual app found for slug '.$slug],
statusCode: Http::STATUS_NOT_FOUND
);
}

$route = $this->normaliseObject(object: $routeResults[0]);
$applicationUuid = ($route['applicationUuid'] ?? null);

if ($applicationUuid === null) {
return new JSONResponse(
data: ['error' => 'inconsistent_state', 'message' => 'Route exists but has no applicationUuid'],
statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
);
}

$application = $this->objectService->find(
id: $applicationUuid,
register: 'openbuilt',
schema: 'application'
);

if ($application === null) {
return new JSONResponse(
data: ['error' => 'not_found', 'message' => 'Application not found'],
statusCode: Http::STATUS_NOT_FOUND
);
}

$applicationArray = $this->normaliseObject(object: $application);

$fromBlob = $this->resolveVersionBlob(token: $from, application: $applicationArray, applicationUuid: $applicationUuid);
if ($fromBlob === null) {
return new JSONResponse(
data: ['error' => 'not_found', 'message' => 'from version not found: '.$from],
statusCode: Http::STATUS_NOT_FOUND
);
}

$toBlob = $this->resolveVersionBlob(token: $to, application: $applicationArray, applicationUuid: $applicationUuid);
if ($toBlob === null) {
return new JSONResponse(
data: ['error' => 'not_found', 'message' => 'to version not found: '.$to],
statusCode: Http::STATUS_NOT_FOUND
);
}

return new JSONResponse(
data: ['from' => $fromBlob, 'to' => $toBlob],
statusCode: Http::STATUS_OK
);
} catch (\Throwable $e) {
$this->logger->error('OpenBuilt: diffVersions failed for slug '.$slug.': '.$e->getMessage(), ['exception' => $e]);
return new JSONResponse(
data: ['error' => 'internal_error', 'message' => 'Failed to resolve diff'],
statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
);
}//end try
}//end diffVersions()

/**
* Resolve a `from`/`to` token to a `{ manifest, version, publishedAt }` blob.
*
* The literal string `draft` returns the Application's current draft
* fields. Any other value is treated as an ApplicationVersion UUID
* and looked up via OR's ObjectService. Returns null on miss so the
* caller can surface 404.
*
* @param string $token Token (`draft` or UUID).
* @param array<string, mixed> $application Normalised Application data.
* @param string $applicationUuid Parent Application UUID for scoping.
*
* @return array<string, mixed>|null Blob or null if the version is missing.
*/
private function resolveVersionBlob(string $token, array $application, string $applicationUuid): ?array
{
if ($token === 'draft') {
return [
'manifest' => ($application['manifest'] ?? null),
'version' => ($application['version'] ?? null),
'publishedAt' => null,
];
}

$version = $this->objectService->find(
id: $token,
register: 'openbuilt',
schema: 'application-version'
);

if ($version === null) {
return null;
}

$versionArray = $this->normaliseObject(object: $version);

// Organisation-scope enforcement: a snapshot from another Application is a miss.
if (($versionArray['applicationUuid'] ?? null) !== $applicationUuid) {
return null;
}

return [
'manifest' => ($versionArray['manifest'] ?? null),
'version' => ($versionArray['version'] ?? null),
'publishedAt' => ($versionArray['publishedAt'] ?? null),
];
}//end resolveVersionBlob()

/**
* Coerce an OR result entry (ObjectEntity or array) to a plain associative array.
*
Expand Down
Loading
Loading