Europass becomes an internal plugin #99

Closed
opened 2026-09-07 15:26:20 +00:00 by tiagoagueda · 3 comments
Owner

Observation

in this context the already existing one internal plugins must comply with this, and the
implementations of Europass import XML and JSON, SMTP and Multiple contacts per user, will
be upgraded to internal shipped plugins

This is the part of that request which fits the architecture as it stands, and improves it.

Why it is a good idea

resume/europass.py already has the shape of a plugin without being one:

def read(data: bytes) -> Record: ...      # sniffs the format, dispatches
def read_xml(data: bytes) -> Record: ...
def read_json(data: bytes) -> Record: ...
def apply(owner, record) -> Report: ...

read decides between the two formats on the first character, which is exactly the
can_handle / parse split sources already use. And Europass is not the last CV format
anybody will want: JSON Resume, HR-XML, a LinkedIn export, a plain PDF. Each is somebody
else's itch, which is the argument the plugin system was built on -- registry.py puts it
as "the person who cares about a particular job board... should not have to wait for this
project to accept a patch"
.

What is missing

There is no importer kind. The four are source, notifier, store, sync, and an
importer is none of them: a source reads a job posting off a page, an importer reads a
person's career out of a file. Different input, different output, different failure mode.

So this issue is mostly the new kind:

  • postulo.importers as an entry-point group, added to GROUPS.
  • An ImporterPlugin protocol: can_handle(data, filename) -> bool, read(data) -> Record,
    and the identity fields from #97.
  • The upload page offers whatever is installed rather than naming Europass.
  • Europass XML and JSON registered as two internal importers, or one that handles both --
    worth deciding: they share apply() and a Record, and read() already sniffs between
    them, so one importer with two formats is closer to the truth than two importers. The
    request says "XML and JSON" as two; the code says one thing that reads both. Suggest one,
    named for the format family, saying which dialect it found -- which Record already does.

Two things an importer must not inherit from a source

A source is given a URL and HTML. An importer is given a file somebody uploaded, which
is a different threat. europass.py refuses a DOCTYPE before parsing -- "a DOCTYPE is
where entity expansion lives, and the point is to refuse it rather than to hand it to a
parser and hope"
-- and caps the file at MAX_BYTES. Those refusals belong to the kind,
enforced by Postulo before a plugin sees a byte, not left to each plugin to remember. A
third-party importer that forgets the DOCTYPE check would be an XXE hole in an application
holding people's CVs.

A source's output is reviewed by a person before anything is saved; base.py is explicit
that a parser guessing wrong should "waste a few seconds of somebody's attention, not put a
fabricated job title into their records"
. An import writes a career record. The same rule
has to hold, and EuropassImportView already works that way -- the preview is not a nicety
to be dropped when the reader becomes a plugin.

Scope

  • The kind, its protocol, its entry-point group, its registration.
  • Size and DOCTYPE refusals enforced by the kind rather than per plugin.
  • Europass moved behind it, keeping apply() in core -- an importer produces a Record; it
    does not write to the database. That boundary is what keeps a third-party importer from
    needing ownership scoping of its own.
  • tests/security/: an importer never bypasses the preview; a hostile file is refused by the
    kind even when the plugin would have accepted it.
  • docs/PLUGINS.md gains the kind.

Classification

Enhancement. Depends on #97 for the identity fields, and on nothing else.

## Observation > in this context the already existing one internal plugins must comply with this, and the > implementations of Europass import XML and JSON, SMTP and Multiple contacts per user, will > be upgraded to internal shipped plugins This is the part of that request which fits the architecture as it stands, and improves it. ## Why it is a good idea `resume/europass.py` already has the shape of a plugin without being one: ```python def read(data: bytes) -> Record: ... # sniffs the format, dispatches def read_xml(data: bytes) -> Record: ... def read_json(data: bytes) -> Record: ... def apply(owner, record) -> Report: ... ``` `read` decides between the two formats on the first character, which is exactly the `can_handle` / `parse` split sources already use. And Europass is not the last CV format anybody will want: JSON Resume, HR-XML, a LinkedIn export, a plain PDF. Each is somebody else's itch, which is the argument the plugin system was built on -- `registry.py` puts it as *"the person who cares about a particular job board... should not have to wait for this project to accept a patch"*. ## What is missing **There is no importer kind.** The four are `source`, `notifier`, `store`, `sync`, and an importer is none of them: a source reads a *job posting* off a *page*, an importer reads a *person's career* out of a *file*. Different input, different output, different failure mode. So this issue is mostly the new kind: - `postulo.importers` as an entry-point group, added to `GROUPS`. - An `ImporterPlugin` protocol: `can_handle(data, filename) -> bool`, `read(data) -> Record`, and the identity fields from #97. - The upload page offers whatever is installed rather than naming Europass. - Europass XML and JSON registered as two internal importers, or one that handles both -- worth deciding: they share `apply()` and a `Record`, and `read()` already sniffs between them, so **one importer with two formats is closer to the truth than two importers**. The request says "XML and JSON" as two; the code says one thing that reads both. Suggest one, named for the format family, saying which dialect it found -- which `Record` already does. ## Two things an importer must not inherit from a source **A source is given a URL and HTML. An importer is given a file somebody uploaded**, which is a different threat. `europass.py` refuses a DOCTYPE *before parsing* -- *"a DOCTYPE is where entity expansion lives, and the point is to refuse it rather than to hand it to a parser and hope"* -- and caps the file at `MAX_BYTES`. Those refusals belong to the kind, enforced by Postulo before a plugin sees a byte, not left to each plugin to remember. A third-party importer that forgets the DOCTYPE check would be an XXE hole in an application holding people's CVs. **A source's output is reviewed by a person before anything is saved**; `base.py` is explicit that a parser guessing wrong should *"waste a few seconds of somebody's attention, not put a fabricated job title into their records"*. An import writes a career record. The same rule has to hold, and `EuropassImportView` already works that way -- the preview is not a nicety to be dropped when the reader becomes a plugin. ## Scope - The kind, its protocol, its entry-point group, its registration. - Size and DOCTYPE refusals enforced by the kind rather than per plugin. - Europass moved behind it, keeping `apply()` in core -- an importer produces a `Record`; it does not write to the database. That boundary is what keeps a third-party importer from needing ownership scoping of its own. - `tests/security/`: an importer never bypasses the preview; a hostile file is refused by the kind even when the plugin would have accepted it. - `docs/PLUGINS.md` gains the kind. ## Classification Enhancement. Depends on #97 for the identity fields, and on nothing else.
tiagoagueda added this to the 0.3.0 milestone 2026-09-07 15:26:20 +00:00
Author
Owner

Narrowed

lets go with a simplier, internal plugin Europass

Right, and the original was doing two things at once. One internal Europass importer,
registered the way the other built-ins already are. The third-party surface is deferred
—
filed on 0.4.0 so it is not lost.

Also settling the one-or-two question the issue raised, in the same direction: one
importer that reads both formats
, because that is what the code already is. read()
sniffs the first character and dispatches; read_xml and read_json return the same
Record; apply() is shared. Two plugins would be one thing described twice.

What it actually costs, using the machinery that is already there

register_builtin is how EmailNotifier and LocalStore are registered — five lines in an
AppConfig.ready():

def ready(self) -> None:
    from postulo.plugins import registry
    from .importers import EuropassImporter

    registry.register_builtin("importer", EuropassImporter)

and built-ins skip the protocol check entirely — plugins() instantiates them directly:

_cache[kind] = [
    *_load_third_party(kind),
    *(plugin_class() for plugin_class in _builtin.get(kind, [])),
]

So the whole of it is: one entry in GROUPS, a class wrapping the read() that exists, one
register_builtin call, the identity fields from #97, and the import view asking the
registry rather than naming Europass. No protocol contract to get right, no
docs/PLUGINS.md chapter, no third-party security review.

One thing that should still be done, and it is a move rather than a build

GROUPS maps a kind to an entry-point group, so adding importer names
postulo.importers whether or not anybody is told about it. Nothing publishes there, so it
returns nothing and costs nothing — but the door is ajar rather than shut, and a third-party
importer that loaded would not have europass.py's protections:

head = data[:4096].lstrip()
if re.search(rb"<!DOCTYPE", head, re.I):
    raise EuropassError(...)

So move the DOCTYPE and size refusals into the kind now. It is relocating code that
already exists and is already correct, one level up, and it is cheaper than the alternative
(a branch in plugins() to keep built-ins off the entry-point path). With that done the open
door is harmless, and the 0.4.0 issue becomes documentation rather than security work.

Unchanged

The preview stays. EuropassImportView shows what was read before anything is written, and
an import writes a career record — base.py's rule that a plugin should "waste a few
seconds of somebody's attention, not put a fabricated job title into their records"
applies
at least as strongly here. apply() stays in core: an importer produces a Record and does
not touch the database, which is what keeps an importer from needing ownership scoping of its
own.

## Narrowed > lets go with a simplier, internal plugin Europass Right, and the original was doing two things at once. **One internal Europass importer, registered the way the other built-ins already are. The third-party surface is deferred** — filed on 0.4.0 so it is not lost. Also settling the one-or-two question the issue raised, in the same direction: **one importer that reads both formats**, because that is what the code already is. `read()` sniffs the first character and dispatches; `read_xml` and `read_json` return the same `Record`; `apply()` is shared. Two plugins would be one thing described twice. ## What it actually costs, using the machinery that is already there `register_builtin` is how `EmailNotifier` and `LocalStore` are registered — five lines in an `AppConfig.ready()`: ```python def ready(self) -> None: from postulo.plugins import registry from .importers import EuropassImporter registry.register_builtin("importer", EuropassImporter) ``` and built-ins skip the protocol check entirely — `plugins()` instantiates them directly: ```python _cache[kind] = [ *_load_third_party(kind), *(plugin_class() for plugin_class in _builtin.get(kind, [])), ] ``` So the whole of it is: one entry in `GROUPS`, a class wrapping the `read()` that exists, one `register_builtin` call, the identity fields from #97, and the import view asking the registry rather than naming Europass. No protocol contract to get right, no `docs/PLUGINS.md` chapter, no third-party security review. ## One thing that should still be done, and it is a move rather than a build `GROUPS` maps a kind to an entry-point group, so adding `importer` names `postulo.importers` whether or not anybody is told about it. Nothing publishes there, so it returns nothing and costs nothing — but the door is ajar rather than shut, and a third-party importer that loaded would not have `europass.py`'s protections: ```python head = data[:4096].lstrip() if re.search(rb"<!DOCTYPE", head, re.I): raise EuropassError(...) ``` **So move the DOCTYPE and size refusals into the kind now.** It is relocating code that already exists and is already correct, one level up, and it is cheaper than the alternative (a branch in `plugins()` to keep built-ins off the entry-point path). With that done the open door is harmless, and the 0.4.0 issue becomes documentation rather than security work. ## Unchanged The preview stays. `EuropassImportView` shows what was read before anything is written, and an import writes a career record — `base.py`'s rule that a plugin should *"waste a few seconds of somebody's attention, not put a fabricated job title into their records"* applies at least as strongly here. `apply()` stays in core: an importer produces a `Record` and does not touch the database, which is what keeps an importer from needing ownership scoping of its own.
tiagoagueda changed title from An importer is a kind of plugin, and Europass is the first two to Europass becomes an internal plugin 2026-09-07 16:32:11 +00:00
Author
Owner

Logos are now asked for as well — filed as #106, which covers where a plugin logo
lives, how it is served under img-src 'self', and the format question jobs/logos.py
already answered once for company logos.

The Europass logo specifically needs a decision rather than a download: it is a European
Union trademark, and AGPL grants rights to code and cannot sublicense somebody else's mark.
That is in #106 with the options. Not blocking this issue — a plugin with no logo has to
render properly regardless, so the importer can land before the artwork does.

Logos are now asked for as well — filed as #106, which covers where a plugin logo lives, how it is served under `img-src 'self'`, and the format question `jobs/logos.py` already answered once for company logos. The Europass logo specifically needs a decision rather than a download: it is a European Union trademark, and AGPL grants rights to code and cannot sublicense somebody else's mark. That is in #106 with the options. **Not blocking this issue** — a plugin with no logo has to render properly regardless, so the importer can land before the artwork does.
Author
Owner

Done in df99ca9, and the dependency on #97 removed rather than waited on: this was built with the identity fields that exist today — name, version, kind, label, description — and #98 sweeps every built-in when #97 lands, this one included.

Worth recording that Forgejo refused the Closes #99 in the commit because of that dependency. An issue with an unresolved dependency cannot be closed by a commit, so a speculative dependency does not merely describe an order, it silently prevents the issue from ever closing itself.

Done in `df99ca9`, and the dependency on #97 removed rather than waited on: this was built with the identity fields that exist today — `name`, `version`, `kind`, `label`, `description` — and #98 sweeps every built-in when #97 lands, this one included. Worth recording that Forgejo refused the `Closes #99` in the commit **because** of that dependency. An issue with an unresolved dependency cannot be closed by a commit, so a speculative dependency does not merely describe an order, it silently prevents the issue from ever closing itself.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
Postulo/postulo#99
No description provided.