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
2 changes: 1 addition & 1 deletion Docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer

COPY fs_overlay/ /

RUN a2enmod rewrite && a2enconf short-url
RUN a2enmod rewrite proxy proxy_http && a2enconf short-url

FROM configured-mw AS production-mw

Expand Down
7 changes: 7 additions & 0 deletions Docker/fs_overlay/etc/apache2/conf-available/short-url.conf
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
<Directory /var/www/html>
RewriteEngine on

# Serve the bare Subject concept URI ($wgNeoWikiRdfBaseUri/entity/{id}) that the RDF export mints,
# so it dereferences (303 to the Subject RDF or its page). MediaWiki's REST router matches on the
# request path, so the request must reach it as /w/rest.php/... A plain internal rewrite leaves
# REQUEST_URI unchanged and the router would 404, so this is an internal self-proxy: it re-requests
# the REST route without a client-visible redirect. Requires mod_proxy + mod_proxy_http.
RewriteRule ^/?entity/(.+)$ http://127.0.0.1/w/rest.php/neowiki/v0/entity/$1 [P,L]

RewriteRule ^/?wiki(/.*)?$ %{DOCUMENT_ROOT}/w/index.php [L]
RewriteRule ^/?$ %{DOCUMENT_ROOT}/w/index.php [L]

Expand Down
1 change: 1 addition & 0 deletions docs/api/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Read, change, and validate Subjects. New Subjects are created on a page — see
|---|---|
| `GET /neowiki/v0/subject/{subjectId}` | Fetch a Subject. Optional `revisionId`; `expand` with `page` or `relations`. |
| `GET /neowiki/v0/subject/{subjectId}/rdf` | Export one Subject as RDF. `format` is `trig` (default) or `turtle`; `projection` is `native` (default) or an ontology target. See [RDF export](../rdf/rdf-export.md). |
| `GET /neowiki/v0/entity/{subjectId}` | Dereference a Subject's concept URI. `303` to the Subject's RDF (`Accept: application/trig` or `text/turtle`) or to the hosting page (otherwise). See [Dereferencing subject IRIs](../rdf/rdf-export.md#dereferencing-subject-iris). |
| `PUT /neowiki/v0/subject/{subjectId}` | Replace a Subject's label and statements. |
| `DELETE /neowiki/v0/subject/{subjectId}` | Delete a Subject. |
| `POST /neowiki/v0/subject/validate` | Check whether a new Subject is valid, without saving it. |
Expand Down
32 changes: 32 additions & 0 deletions docs/rdf/rdf-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,38 @@ for the requested ontology target projects to an empty graph — a `200`, not a
curl 'https://wiki.example/rest.php/neowiki/v0/subject/s1demo8aaaaaab5/rdf?projection=EDM'
```

### Dereferencing subject IRIs

Every Subject's `neo-subj:` IRI — `$base/entity/{subjectId}` — is a dereferenceable concept URI. A `GET`
content-negotiates it and answers `303 See Other` with an absolute `Location`:

| `Accept` | Redirects to |
|---|---|
| `application/trig` | the Subject's TriG RDF (`.../subject/{id}/rdf?format=trig`) |
| `text/turtle` | the Subject's Turtle RDF (`.../subject/{id}/rdf?format=turtle`) |
| `text/html`, `*/*`, absent, anything else | the Subject's hosting page |

TriG wins when both RDF types are acceptable; the RDF redirects use the native projection. A Subject that
is absent or on a page the caller may not read returns one indistinguishable `404`; a malformed id `400`.

The negotiator is always reachable at the REST path, which needs no server configuration:

```sh
curl -H 'Accept: text/turtle' 'https://wiki.example/rest.php/neowiki/v0/entity/s1demo8aaaaaab5'
```

To make the bare `neo-subj:` IRI itself dereference, route `/entity/{id}` to that REST path. MediaWiki's
REST router matches on the request path, so an internal self-proxy (not a plain rewrite, which would leave
the path unchanged) presents it without a client-visible redirect. The dev image ships this, on Apache with
`mod_proxy` and `mod_proxy_http`:

```apache
RewriteRule ^/?entity/(.+)$ http://127.0.0.1/w/rest.php/neowiki/v0/entity/$1 [P,L]
```

This applies when `$wgNeoWikiRdfBaseUri` is the wiki's own host (the default); an external or institutional
base URI is the operator's own routing concern.

### Finding these exports

These exports are surfaced in the UI: the Data tab (`?action=subjects`) links to each Subject's JSON and
Expand Down
9 changes: 9 additions & 0 deletions extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,11 @@
"method": [ "GET" ],
"factory": "ProfessionalWiki\\NeoWiki\\NeoWikiExtension::newExportSubjectRdfApi"
},
{
"path": "/neowiki/v0/entity/{subjectId}",
"method": [ "GET" ],
"factory": "ProfessionalWiki\\NeoWiki\\NeoWikiExtension::newResolveSubjectIriApi"
},
{
"path": "/neowiki/v0/schema/{schemaName}",
"method": [ "GET" ],
Expand Down Expand Up @@ -599,6 +604,10 @@
"neowiki-managesubjects-id-copy",
"neowiki-managesubjects-id-copied",
"neowiki-managesubjects-id-copy-error",
"neowiki-managesubjects-iri-label",
"neowiki-managesubjects-iri-copy",
"neowiki-managesubjects-iri-copied",
"neowiki-managesubjects-iri-copy-error",
"neowiki-managesubjects-export-button",
"neowiki-managesubjects-export-json",
"neowiki-managesubjects-export-native",
Expand Down
4 changes: 4 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,10 @@
"neowiki-managesubjects-id-copy": "Copy subject ID $1",
"neowiki-managesubjects-id-copied": "Copied \"$1\" to clipboard.",
"neowiki-managesubjects-id-copy-error": "Couldn't copy the subject ID.",
"neowiki-managesubjects-iri-label": "IRI:",
"neowiki-managesubjects-iri-copy": "Copy subject IRI $1",
"neowiki-managesubjects-iri-copied": "Copied \"$1\" to clipboard.",
"neowiki-managesubjects-iri-copy-error": "Couldn't copy the subject IRI.",
"neowiki-managesubjects-export-button": "Export",
"neowiki-managesubjects-export-json": "JSON",
"neowiki-managesubjects-export-native": "Native",
Expand Down
4 changes: 4 additions & 0 deletions i18n/qqq.json
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,10 @@
"neowiki-managesubjects-id-copy": "Tooltip on the subject ID button. $1 is the Subject ID. Click copies the ID to the clipboard.",
"neowiki-managesubjects-id-copied": "Notification shown after the subject ID was copied. $1 is the Subject ID.",
"neowiki-managesubjects-id-copy-error": "Notification shown when copying the subject ID to the clipboard failed.",
"neowiki-managesubjects-iri-label": "Inline label shown before the Subject IRI (its RDF concept URI) in the footer of an expanded Subject row. Typically short, e.g. \"IRI:\".",
"neowiki-managesubjects-iri-copy": "Tooltip on the subject IRI button. $1 is the Subject IRI. Click copies the IRI to the clipboard.",
"neowiki-managesubjects-iri-copied": "Notification shown after the subject IRI was copied. $1 is the Subject IRI.",
"neowiki-managesubjects-iri-copy-error": "Notification shown when copying the subject IRI to the clipboard failed.",
"neowiki-managesubjects-export-button": "Label and accessible name of the button that opens the data export menu on the Data tab (per Subject, and for the whole page in the header).",
"neowiki-managesubjects-export-json": "Export menu item that downloads the raw JSON representation of the Subject or page.",
"neowiki-managesubjects-export-native": "Display name of the native (NeoWiki-vocabulary) RDF projection, used as $1 in {{msg-neowiki|neowiki-managesubjects-export-turtle}} and {{msg-neowiki|neowiki-managesubjects-export-trig}}.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,22 +171,40 @@
<div class="ext-neowiki-subjects-manager__row-expanded">
<SubjectStatementsView :subject="mainSubject" />
<footer class="ext-neowiki-subjects-manager__row-footer">
<span class="ext-neowiki-subjects-manager__row-id">
<span class="ext-neowiki-subjects-manager__row-id-label">
{{ $i18n( 'neowiki-managesubjects-id-label' ).text() }}
<div class="ext-neowiki-subjects-manager__row-identifiers">
<span class="ext-neowiki-subjects-manager__row-id">
<span class="ext-neowiki-subjects-manager__row-id-label">
{{ $i18n( 'neowiki-managesubjects-id-label' ).text() }}
</span>
<button
type="button"
class="ext-neowiki-subjects-manager__row-id-button"
:title="$i18n( 'neowiki-managesubjects-id-copy', mainSubject.getId().text ).text()"
:aria-label="$i18n( 'neowiki-managesubjects-id-copy', mainSubject.getId().text ).text()"
@click="copySubjectId( mainSubject.getId().text )"
>
<data :value="mainSubject.getId().text">
{{ mainSubject.getId().text }}
</data>
</button>
</span>
<button
type="button"
class="ext-neowiki-subjects-manager__row-id-button"
:title="$i18n( 'neowiki-managesubjects-id-copy', mainSubject.getId().text ).text()"
:aria-label="$i18n( 'neowiki-managesubjects-id-copy', mainSubject.getId().text ).text()"
@click="copySubjectId( mainSubject.getId().text )"
>
<data :value="mainSubject.getId().text">
{{ mainSubject.getId().text }}
</data>
</button>
</span>
<span class="ext-neowiki-subjects-manager__row-iri">
<span class="ext-neowiki-subjects-manager__row-iri-label">
{{ $i18n( 'neowiki-managesubjects-iri-label' ).text() }}
</span>
<button
type="button"
class="ext-neowiki-subjects-manager__row-iri-button"
:title="$i18n( 'neowiki-managesubjects-iri-copy', subjectIri( mainSubject.getId().text ) ).text()"
:aria-label="$i18n( 'neowiki-managesubjects-iri-copy', subjectIri( mainSubject.getId().text ) ).text()"
@click="copySubjectIri( subjectIri( mainSubject.getId().text ) )"
>
<data :value="subjectIri( mainSubject.getId().text )">
{{ subjectIri( mainSubject.getId().text ) }}
</data>
</button>
</span>
</div>
<CdxMenuButton
v-model:selected="exportMenuSelection"
class="ext-neowiki-subjects-manager__export-menu"
Expand Down Expand Up @@ -327,22 +345,40 @@
<div class="ext-neowiki-subjects-manager__row-expanded">
<SubjectStatementsView :subject="subject" />
<footer class="ext-neowiki-subjects-manager__row-footer">
<span class="ext-neowiki-subjects-manager__row-id">
<span class="ext-neowiki-subjects-manager__row-id-label">
{{ $i18n( 'neowiki-managesubjects-id-label' ).text() }}
<div class="ext-neowiki-subjects-manager__row-identifiers">
<span class="ext-neowiki-subjects-manager__row-id">
<span class="ext-neowiki-subjects-manager__row-id-label">
{{ $i18n( 'neowiki-managesubjects-id-label' ).text() }}
</span>
<button
type="button"
class="ext-neowiki-subjects-manager__row-id-button"
:title="$i18n( 'neowiki-managesubjects-id-copy', subject.getId().text ).text()"
:aria-label="$i18n( 'neowiki-managesubjects-id-copy', subject.getId().text ).text()"
@click="copySubjectId( subject.getId().text )"
>
<data :value="subject.getId().text">
{{ subject.getId().text }}
</data>
</button>
</span>
<button
type="button"
class="ext-neowiki-subjects-manager__row-id-button"
:title="$i18n( 'neowiki-managesubjects-id-copy', subject.getId().text ).text()"
:aria-label="$i18n( 'neowiki-managesubjects-id-copy', subject.getId().text ).text()"
@click="copySubjectId( subject.getId().text )"
>
<data :value="subject.getId().text">
{{ subject.getId().text }}
</data>
</button>
</span>
<span class="ext-neowiki-subjects-manager__row-iri">
<span class="ext-neowiki-subjects-manager__row-iri-label">
{{ $i18n( 'neowiki-managesubjects-iri-label' ).text() }}
</span>
<button
type="button"
class="ext-neowiki-subjects-manager__row-iri-button"
:title="$i18n( 'neowiki-managesubjects-iri-copy', subjectIri( subject.getId().text ) ).text()"
:aria-label="$i18n( 'neowiki-managesubjects-iri-copy', subjectIri( subject.getId().text ) ).text()"
@click="copySubjectIri( subjectIri( subject.getId().text ) )"
>
<data :value="subjectIri( subject.getId().text )">
{{ subjectIri( subject.getId().text ) }}
</data>
</button>
</span>
</div>
<CdxMenuButton
v-model:selected="exportMenuSelection"
class="ext-neowiki-subjects-manager__export-menu"
Expand Down Expand Up @@ -448,6 +484,10 @@ const pageId = Number( mw.config.get( 'wgNeoWikiManageSubjectsPageId' ) );
// server-side. Drives the export menus; native is always present, so this is never truly empty.
const rdfProjections = ( mw.config.get( 'wgNeoWikiRdfProjections' ) as string[] | null ) ?? [];

// The `$base/entity/` prefix a Subject id extends into its RDF concept URI, derived server-side from
// the same rule the export mints IRIs with. The copy-IRI control appends the Subject id to it.
const subjectIriBase = ( mw.config.get( 'wgNeoWikiSubjectIriBase' ) as string | null ) ?? '';

const subjectStore = useSubjectStore();
const schemaStore = useSchemaStore();
const {
Expand Down Expand Up @@ -534,6 +574,10 @@ function schemaUrl( name: string ): string {
return mw.util.getUrl( `Schema:${ name }` );
}

function subjectIri( id: string ): string {
return subjectIriBase + id;
}

function statementCount( subject: Subject ): number {
return subject.getStatements().withNonEmptyValues().getPropertyNames().length;
}
Expand Down Expand Up @@ -636,6 +680,16 @@ async function copySubjectId( id: string ): Promise<void> {
}
}

async function copySubjectIri( iri: string ): Promise<void> {
try {
await navigator.clipboard.writeText( iri );
mw.notify( mw.msg( 'neowiki-managesubjects-iri-copied', iri ), { type: 'success' } );
} catch ( error ) {
console.error( 'Failed to copy subject IRI:', error );
mw.notify( mw.msg( 'neowiki-managesubjects-iri-copy-error' ), { type: 'error' } );
}
}

async function loadSubjects(): Promise<void> {
loading.value = true;
try {
Expand Down Expand Up @@ -1073,13 +1127,28 @@ onUnmounted( () => {
white-space: nowrap;
}

&__row-id {
display: inline-flex;
&__row-identifiers {
display: flex;
flex-direction: column;
gap: @spacing-25;
min-width: 0;
}

&__row-id,
&__row-iri {
display: flex;
align-items: baseline;
gap: @spacing-25;
min-width: 0;
}

&__row-id-button {
&__row-id-label,
&__row-iri-label {
flex-shrink: 0;
}

&__row-id-button,
&__row-iri-button {
appearance: none;
background: transparent;
border: 0;
Expand All @@ -1088,6 +1157,12 @@ onUnmounted( () => {
color: inherit;
font: inherit;
font-family: @font-family-monospace;
// The IRI is a full URL: let a long one ellipsize instead of stretching the footer. The whole
// value stays in the button title and is what the click copies.
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

&:hover {
color: @color-base;
Expand Down
46 changes: 46 additions & 0 deletions src/Application/Rdf/SubjectHostingPageResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NeoWiki\Application\Rdf;

use ProfessionalWiki\NeoWiki\Application\PageIdentifiersLookup;
use ProfessionalWiki\NeoWiki\Application\PageReadAuthorizer;
use ProfessionalWiki\NeoWiki\Domain\Page\PageIdentifiers;
use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId;

/**
* Resolves a Subject to the page that hosts it, gated by read access: the hosting page's identifiers
* when the Subject is in the graph and the caller may read its page, and null for every reason it
* cannot be served — the Subject is not in the graph, or its hosting page is unreadable or gone (an
* unresolvable page id has nothing to authorize, so the authorizer denies it).
*
* The concept-URI negotiator ({@see \ProfessionalWiki\NeoWiki\EntryPoints\REST\ResolveSubjectIriApi})
* uses this to decide its one indistinguishable not-found, byte identical whether the Subject is absent
* or on a page the caller cannot read, so a harvested Subject id cannot be probed for existence (#1046).
* This is the resolve→authorize gate of {@see RdfSubjectExporter} without loading page content: the
* negotiator only needs the hosting page to redirect to, not the Subject's triples.
*/
readonly class SubjectHostingPageResolver {

public function __construct(
private PageIdentifiersLookup $pageIdentifiersLookup,
private PageReadAuthorizer $readAuthorizer,
) {
}

public function resolveReadableHostingPage( SubjectId $subjectId ): ?PageIdentifiers {
$pageIdentifiers = $this->pageIdentifiersLookup->getPageIdOfSubject( $subjectId );

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

if ( !$this->readAuthorizer->authorizeReadByPageId( $pageIdentifiers->getId() ) ) {
return null;
}

return $pageIdentifiers;
}

}
14 changes: 12 additions & 2 deletions src/Domain/Rdf/RdfNamespaces.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,17 @@ public function __construct( string $baseUri ) {
}

public function subject( SubjectId $id ): Iri {
return new Iri( $this->baseUri . '/entity/' . $id->text );
return new Iri( $this->subjectIriBase() . $id->text );
}

/**
* The IRI prefix a Subject id extends into its concept URI ({@see subject()}) — `$base/entity/`,
* the `neo-subj:` namespace. Exposed so surfaces that build a Subject IRI without a SubjectId in
* hand (the Data tab's copy-IRI control, seeded via a JS config var) share this one rule rather
* than re-deriving the `/entity/` path and the base-URI trimming.
*/
public function subjectIriBase(): string {
return $this->baseUri . '/entity/';
}

public function property( string $propertyName ): Iri {
Expand Down Expand Up @@ -149,7 +159,7 @@ private static function isIriLocalByte( string $byte ): bool {
public function prefixMap(): array {
return [
'neo' => $this->baseUri . '/ontology/',
'neo-subj' => $this->baseUri . '/entity/',
'neo-subj' => $this->subjectIriBase(),
'neo-prop' => $this->baseUri . '/prop/',
'neo-schema' => $this->baseUri . '/schema/',
'neo-rel' => $this->baseUri . '/relation/',
Expand Down
3 changes: 3 additions & 0 deletions src/EntryPoints/Actions/SubjectsAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ public function onView(): string {
$extension->getRdfProjectionNames(),
$this->getAuthority()
),
// The copy-IRI control appends the Subject id to this base to show the full neo-subj:
// concept URI, deriving it from the same server-side rule the RDF export mints IRIs with.
'wgNeoWikiSubjectIriBase' => $extension->getRdfNamespaces()->subjectIriBase(),
] );

return Html::element( 'div', [ 'id' => 'ext-neowiki-manage-subjects' ] );
Expand Down
Loading
Loading