diff --git a/CHANGELOG.md b/CHANGELOG.md
index cc5f3fb..4fa6ba0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,7 +8,7 @@ version may change things that need attention when updating, and these notes say
### For curators
- Each item can record what others may do with it, as a standard statement with a plain label, who holds the rights, and a
- credit. Settings has a default. A contributor's answer only suggests a statement, and never a free licence.
+ credit. Settings has a default. A contributor's answer only suggests a statement, and only a free licence if they offered one.
- Catalogue details for curators only: reference number, where the original is kept, received from and when, internal notes.
- Each photo can say what it shows, for people who cannot see it. Visitors' pages use this as the picture's description.
- Filters for items without a rights statement or photo descriptions. A reference number typed into the search finds its item.
@@ -30,6 +30,10 @@ version may change things that need attention when updating, and these notes say
### For visitors
+- Every item says in plain language whether it may be reused, and who to credit, with a link to what the statement means and to the
+ archive's contact page. Without a statement, it says to ask.
+- "Cite this item" gives a line to copy, and "Share" opens a phone's own list of where to share to, or copies the link.
+- A link to an item pasted into a messenger shows its picture, title and the beginning of its text.
- Photos can be opened large, about 3000 pixels, to read a letter or look at a face. It is the browser's own view of a picture,
which can zoom on every device, with nothing to load or learn.
- An item with several photos has a row of numbered thumbnails, and says "Photo 2 of 4" with each caption.
@@ -57,9 +61,19 @@ version may change things that need attention when updating, and these notes say
connection does not make two contributions, also when the first one arrived.
- The title, date and description stay on the device for three days until they are sent, with a button to clear them. Name,
email address and the photographer's name are never kept. Without JavaScript the form works as before.
+- Someone who made a photo, or may decide for whoever did, can offer it for reuse. It is optional, and the answer is only a
+ suggestion to the curators.
+- The consent now also covers link previews and the archive's list of what is new, and says that copies others have made are out of
+ the archive's reach.
### For people who run an archive
+- **Rights statements and credits that curators have written so far now appear on public pages.** The label on the field always said
+ so, but it is worth a look before updating. An item with no statement and no default for the archive tells visitors to ask.
+- **Earlier contributions stay under what they agreed to,** which was "this website". Their picture and text stay out of link previews
+ and the feed. Items that did not come through a contribution, and everything sent in from now on, are in both.
+- An Atom feed at `/feed.xml` and a sitemap at `/sitemap.xml`, named in `robots.txt`. They and link previews are switched off together
+ with "Let search engines list this archive" in "Settings".
- Older photos get the new picture size in the background, with nothing going offline. See docs/operations.md.
- **The large photos are off in an archive that has saved its settings before, because they show more than it has chosen to
show so far.** An admin can switch them on under "Settings" and the archive then makes them in the background. In a new archive,
diff --git a/docs/curator-handbook.md b/docs/curator-handbook.md
index 7299a00..ed6a649 100644
--- a/docs/curator-handbook.md
+++ b/docs/curator-handbook.md
@@ -38,6 +38,16 @@ Open **Rights and credit** on an item:
stay out of public pages and public search. Each photo also has a field for **what it shows**, in a sentence, for people who
cannot see it.
+Visitors see the statement on the item's page, in plain words, and the credit. Without a statement of its own the archive's default
+applies, and without that the page tells them to ask.
+
+If a contributor offered reuse, the statement is already filled in with that, and the review shows what they said. It is still your
+decision. Before you publish, ask yourself whether it was theirs to give: someone who has reused a photo under a free licence may go
+on doing so, even if you take it back later.
+
+Contributions from before this was possible agreed to this website only. Their picture and text stay out of link previews and the feed,
+and that is not something you can change without asking them, and there is no button for it yet.
+
## Working through many items
Open the item list and filter it: by collection, or by what is "still needed", such as a date. Open the first item and use
diff --git a/docs/development.md b/docs/development.md
index 9849dc6..c387555 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -127,6 +127,13 @@ redirects or shows the page again.
again keeps its draft until the browser's storage is cleared.
- **Pictures from the device** need `img-src blob:`. `SecurityHeaders` allows that on `/contribute` only.
+## Beyond the website
+
+`Search/PublicFeeds.cs` has the feed, the sitemap and `LinkPreview`. All of them follow `AllowSearchEngines`. Addresses come from
+the request, so behind Caddy they depend on `ASPNETCORE_FORWARDEDHEADERS_ENABLED` in `compose.production.yaml`; `SharingTests`
+tries that setting. Text goes into XML through `XmlText`, because one pasted control character would make a whole feed unreadable.
+`ArchiveItem.MayTravel` decides what goes along with a link, and it needs the contribution to be loaded.
+
## Photos of an item
`MediaFile.Position` orders an item's photos, and the first is its cover. Anything that picks or lists photos must order by
diff --git a/docs/handbuch-kuratoren.md b/docs/handbuch-kuratoren.md
index 0200f44..36da96d 100644
--- a/docs/handbuch-kuratoren.md
+++ b/docs/handbuch-kuratoren.md
@@ -40,6 +40,16 @@ Besucher können ein Foto mit etwa 3000 Pixeln öffnen. Ob das möglich ist, gil
bleiben aus öffentlichen Seiten und der öffentlichen Suche heraus. Jedes Foto hat außerdem ein Feld dafür, **was es zeigt**, in
einem Satz, für Menschen, die es nicht sehen können.
+Besucher sehen die Angabe in einfachen Worten auf der Seite des Eintrags, dazu die Nennung. Ohne eigene Angabe gilt die Voreinstellung des
+Archivs, und ohne diese bittet die Seite, nachzufragen.
+
+Wenn eine einsendende Person die Weiterverwendung angeboten hat, ist die Angabe schon entsprechend ausgefüllt, und die Prüfung zeigt, was sie
+gesagt hat. Die Entscheidung bleibt bei Ihnen. Fragen Sie sich vor dem Veröffentlichen, ob sie das erlauben durfte: Wer ein Foto unter einer
+freien Lizenz weiterverwendet hat, darf das weiter tun, auch wenn Sie die Lizenz später zurücknehmen.
+
+Beiträge aus der Zeit davor haben nur dieser Website zugestimmt. Ihr Bild und ihr Text bleiben aus Linkvorschauen und dem Feed heraus. Das lässt
+sich nicht ändern, ohne die Person zu fragen, und dafür gibt es noch keine Schaltfläche.
+
## Viele Einträge bearbeiten
Öffnen Sie die Liste der Einträge und filtern Sie sie: nach Sammlung oder danach, was noch fehlt, zum Beispiel ein Datum.
diff --git a/docs/operations.md b/docs/operations.md
index 2cfc4ea..5914b3f 100644
--- a/docs/operations.md
+++ b/docs/operations.md
@@ -49,8 +49,22 @@ Curators have their own guide: [curator-handbook.md](curator-handbook.md) ([deut
## Rights and catalogue details
On each item, curators can say what others may do with it, as a standard statement with a plain label. "Settings" has a
-default for items without one of their own. A contributor's answer only suggests a statement, and never a free licence: they
-agreed to publication on this website, not to reuse by others. Visitors do not see these yet; that comes in a later version.
+default for items without one of their own. Each item page tells visitors what applies, with the credit if there is one. Without a
+statement or a default, it tells them to ask. "Who holds the rights?" stays with curators.
+
+A contributor's answer only suggests a statement: a free licence only if they offered one themselves, and never if they do not know
+who made it. Their offer is stored in the words they saw, as part of the consent. A curator decides, because a free licence cannot be
+taken back from those who have already reused something.
+
+## Beyond this website
+
+If search engines may list the archive, it also has a feed of the latest 30 items at `/feed.xml`, a list of its public pages at
+`/sitemap.xml`, named in `robots.txt`, and what a link shows when it is pasted into a messenger. Switching that setting off ends all
+of them at once, though what was already copied elsewhere stays there.
+
+Contributions sent in before this was added agreed to "this website" only, and stay under that: `consentCoversSharing` is false, so their
+picture and text stay out of previews, and the whole item stays out of the feed. A link to one still shows its title. There is no
+button to change this, because it would take the contributor's agreement, which the archive has no record of.
"Catalogue details" (reference number, where the original is kept, received from and when, notes) are for curators only and
stay out of public pages and public search. A reference number typed into the search on "Manage archive" finds its item.
@@ -117,7 +131,8 @@ the material can always move to another system, or be handed to a public archive
An item's title can be empty in a full export: a draft from "Add many photos" that nobody has named yet. Each file says its
`position` among the item's photos and its `rotation`, how far curators turned it in degrees clockwise. The file itself is the
original, which is never turned, so whoever uses the export has to apply that. Files are listed in order. `needsTranscription` is
-in a full export only.
+in a full export only. A contribution says what the contributor offered (`reuse`) and whether they agreed to more than this website
+(`consentCoversSharing`).
`archive.json` has `"format": "commonage-export"` and `"version": 3`. Version 3 added each item's rights, credit and catalogue
details and each photo's description; in `items.csv`, `rights` and `rights_holder` are now the item's own, and the contributor's
diff --git a/docs/roadmap.md b/docs/roadmap.md
index a74fd90..9622d12 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -171,7 +171,7 @@ real pace over M7 and M8 before promising dates. Each milestone is a minor relea
| **M13 Shoebox** ✅ | 4 | A curator turns 300 scans into drafts with shared values, sees progress and can retry failures, is warned about identical files, and publishes a chosen set in one action. An item without a real title cannot be published. |
| **M14 Phone-first sending** ✅ | 4 | Over slow mobile data, a contributor sees their chosen photos, watches a progress bar and cannot send twice. The new tasks are walked through in the running app and given to the tester, and a week of fixes is done. |
| **M15 Readable items** ✅ | 4 | A visitor reads a scanned letter at full size, sees "Photo 2 of 4" with its caption, and moves on to the next item or to items from the same time. Curators reorder, rotate and describe each photo. |
-| **M16 Rights and sharing** | 3 | Every item page says whether it may be reused, in plain language. "Cite this item" gives a line to copy. A link pasted into a messenger shows the picture and the archive's name. |
+| **M16 Rights and sharing** ✅ | 3 | Every item page says whether it may be reused, in plain language. "Cite this item" gives a line to copy. A link pasted into a messenger shows the picture and the archive's name. |
| **M17 Language reach** | 5 | A translator without Git has tried Weblate on a few pages. Missing translations fail a test. Counts read correctly under Polish plural rules. Item text carries the right `lang`. Dates that are accepted today are still accepted. |
| **M18 Accounts and system** | 4 | A curator logs in with an authenticator app. Login keys are encrypted with a key outside the database, and the backup test still passes. An admin sees version, disk use, failed previews (with retry) and backup age on one page. |
| **M19 Discovery** | 3 | A visitor taps "1960s" and finds the flood, opens "Places" and picks "Mill Street". A curator merges "Mill St" into "Mill Street". |
diff --git a/docs/security.md b/docs/security.md
index 799c50a..ea8deb1 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -26,6 +26,7 @@ vulnerability, see [SECURITY.md](../SECURITY.md).
| Forms | Every form carries an antiforgery token. Redirects after login and language change only go to addresses on this site. |
| Uploads | Files are recognised by their content (magic bytes), never by name or browser type, and stored under random names outside the web root. Originals are never changed or served to visitors. Previews are made by libvips, rotated upright and stripped of all metadata. Images over 250 megapixels get no preview (decompression bombs). Size limits per file and per request. |
| Browser | Strict Content Security Policy (no inline script or style, no framing, no plugins; only the contribution form may show `blob:` images, for the photos someone has chosen on their own device, and a picture opened on its own has no scripts at all but lets the browser's picture view style itself), `nosniff`, `Referrer-Policy: same-origin` (setup links never leak to other sites), `X-Frame-Options`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`. No `Server` header. `SecurityHeaderTests` checks the headers and that no page contains inline script or style, so the policy can stay strict. |
+| Beyond the website | The feed, sitemap and link previews hold nothing a public page does not show, and are switched off with "Let search engines list this archive". Contributions that agreed to "this website" only keep their picture and text out of them. |
| Output | Razor encodes all text. The CSV export prefixes cells that start with `=`, `+`, `-` or `@`, so contributed text cannot become a spreadsheet formula. |
| Database | All queries go through EF Core with parameters; the only raw SQL (`AccentFolding`) contains no input. Contributor search escapes `%` and `_`. |
| Spam | Unlisted contribution links, a honeypot field, a minimum fill time and a per-address limit. Spam gets the normal thank-you page. |
diff --git a/docs/usability-test.md b/docs/usability-test.md
index 8225ee1..dff0849 100644
--- a/docs/usability-test.md
+++ b/docs/usability-test.md
@@ -82,6 +82,9 @@ What to note for task A, besides the usual:
- Whether the chosen photos appear, and how quickly with ten large ones. Whether the page struggles with that.
- Turn on flight mode while it is sending, and off again. What happens, and what does the person do next?
+Paste a link to an item into a messenger on a real phone, on an archive with a real address. Does the picture appear? A preview can
+only be tried that way, because the messenger's servers have to reach the archive.
+
For task B, note whether the person finds "Open at full size", and whether they manage to zoom in and read the letter on the phone, and
in which browser. Nobody has tried that on a real phone yet.
diff --git a/src/Commonage.Web/Data/Migrations/20261106203813_AddReuseAndSharingConsent.Designer.cs b/src/Commonage.Web/Data/Migrations/20261106203813_AddReuseAndSharingConsent.Designer.cs
new file mode 100644
index 0000000..a2cf73e
--- /dev/null
+++ b/src/Commonage.Web/Data/Migrations/20261106203813_AddReuseAndSharingConsent.Designer.cs
@@ -0,0 +1,880 @@
+//
+using System;
+using Commonage.Web.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using NpgsqlTypes;
+
+#nullable disable
+
+namespace Commonage.Web.Data.Migrations
+{
+ [DbContext(typeof(CommonageDbContext))]
+ [Migration("20261106203813_AddReuseAndSharingConsent")]
+ partial class AddReuseAndSharingConsent
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.14")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ArchiveItemTag", b =>
+ {
+ b.Property("ItemsId")
+ .HasColumnType("integer");
+
+ b.Property("TagsId")
+ .HasColumnType("integer");
+
+ b.HasKey("ItemsId", "TagsId");
+
+ b.HasIndex("TagsId");
+
+ b.ToTable("ArchiveItemTag");
+ });
+
+ modelBuilder.Entity("Commonage.Web.Domain.ArchiveItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CollectionId")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Credit")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("DateFrom")
+ .HasColumnType("date");
+
+ b.Property("DateText")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("DateTo")
+ .HasColumnType("date");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasMaxLength(20000)
+ .HasColumnType("character varying(20000)");
+
+ b.Property("InternalNotes")
+ .HasMaxLength(20000)
+ .HasColumnType("character varying(20000)");
+
+ b.Property("NeedsTranscription")
+ .HasColumnType("boolean");
+
+ b.Property("OriginalLocation")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("PublishedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ReceivedFrom")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("ReceivedOn")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("ReferenceNumber")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("RightsHolder")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("RightsUri")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("SearchVector")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("tsvector")
+ .HasComputedColumnSql("setweight(to_tsvector('simple', commonage_fold(coalesce(\"Title\", ''))), 'A') || setweight(to_tsvector('simple', commonage_fold(coalesce(\"TagNames\", '') || ' ' || coalesce(\"DateText\", ''))), 'B') || setweight(to_tsvector('simple', commonage_fold(coalesce(\"Description\", ''))), 'C')", true);
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("TagNames")
+ .HasColumnType("text");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CollectionId");
+
+ b.HasIndex("DateFrom");
+
+ b.HasIndex("DateTo");
+
+ b.HasIndex("ReferenceNumber")
+ .IsUnique();
+
+ b.HasIndex("SearchVector");
+
+ NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN");
+
+ b.HasIndex("Status");
+
+ b.ToTable("ArchiveItems");
+ });
+
+ modelBuilder.Entity("Commonage.Web.Domain.ArchiveSettings", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("integer");
+
+ b.Property("AccentColor")
+ .HasMaxLength(7)
+ .HasColumnType("character varying(7)");
+
+ b.Property("AllowSearchEngines")
+ .HasColumnType("boolean");
+
+ b.Property("DefaultLanguage")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("DefaultRightsUri")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("ImprintNotNeeded")
+ .HasColumnType("boolean");
+
+ b.Property("LargePhotos")
+ .HasColumnType("boolean");
+
+ b.Property("LogoId")
+ .HasColumnType("uuid");
+
+ b.Property("PrivacyReviewed")
+ .HasColumnType("boolean");
+
+ b.Property("RestoreTestedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TimeZone")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.HasKey("Id");
+
+ b.ToTable("ArchiveSettings");
+ });
+
+ modelBuilder.Entity("Commonage.Web.Domain.ArchiveText", b =>
+ {
+ b.Property("Language")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Kind")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("Text")
+ .IsRequired()
+ .HasMaxLength(20000)
+ .HasColumnType("character varying(20000)");
+
+ b.HasKey("Language", "Kind");
+
+ b.ToTable("ArchiveTexts");
+ });
+
+ modelBuilder.Entity("Commonage.Web.Domain.AuditEntry", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("ArchiveItemId")
+ .HasColumnType("integer");
+
+ b.Property("At")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Subject")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("UserId")
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ArchiveItemId");
+
+ b.HasIndex("At");
+
+ b.ToTable("AuditEntries");
+ });
+
+ modelBuilder.Entity("Commonage.Web.Domain.Collection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasMaxLength(4000)
+ .HasColumnType("character varying(4000)");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Collections");
+ });
+
+ modelBuilder.Entity("Commonage.Web.Domain.Contribution", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ArchiveItemId")
+ .HasColumnType("integer");
+
+ b.Property("ConsentCoversSharing")
+ .HasColumnType("boolean");
+
+ b.Property("ConsentText")
+ .IsRequired()
+ .HasMaxLength(4000)
+ .HasColumnType("character varying(4000)");
+
+ b.Property("ContributionLinkId")
+ .HasColumnType("integer");
+
+ b.Property("ContributorEmail")
+ .HasMaxLength(254)
+ .HasColumnType("character varying(254)");
+
+ b.Property("ContributorName")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("PersonalDetailsRemovedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ReferenceCode")
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b.Property("Reuse")
+ .HasColumnType("integer");
+
+ b.Property("ReviewedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Rights")
+ .HasColumnType("integer");
+
+ b.Property("RightsHolder")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("SendingFingerprint")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("ShowName")
+ .HasColumnType("boolean");
+
+ b.Property("SubmittedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ArchiveItemId")
+ .IsUnique();
+
+ b.HasIndex("ContributionLinkId");
+
+ b.HasIndex("ReferenceCode")
+ .IsUnique();
+
+ b.HasIndex("SendingFingerprint")
+ .IsUnique();
+
+ b.ToTable("Contributions");
+ });
+
+ modelBuilder.Entity("Commonage.Web.Domain.ContributionLink", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CollectionId")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Introduction")
+ .HasMaxLength(4000)
+ .HasColumnType("character varying(4000)");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CollectionId");
+
+ b.HasIndex("Token")
+ .IsUnique();
+
+ b.ToTable("ContributionLinks");
+ });
+
+ modelBuilder.Entity("Commonage.Web.Domain.MediaFile", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AltText")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("ArchiveItemId")
+ .HasColumnType("integer");
+
+ b.Property("Caption")
+ .HasColumnType("text");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DerivativeVersion")
+ .HasColumnType("integer");
+
+ b.Property("HasLarge")
+ .HasColumnType("boolean");
+
+ b.Property("OriginalFileName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("Position")
+ .HasColumnType("integer");
+
+ b.Property("PreviewHeight")
+ .HasColumnType("integer");
+
+ b.Property("PreviewWidth")
+ .HasColumnType("integer");
+
+ b.Property("ProcessingError")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("RenderedRotation")
+ .HasColumnType("integer");
+
+ b.Property("Rotation")
+ .HasColumnType("integer");
+
+ b.Property("Sha256")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character(64)")
+ .IsFixedLength();
+
+ b.Property("SizeBytes")
+ .HasColumnType("bigint");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("StorageId")
+ .HasColumnType("uuid");
+
+ b.Property("UploadedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ArchiveItemId");
+
+ b.HasIndex("Sha256");
+
+ b.HasIndex("Status");
+
+ b.HasIndex("StorageId")
+ .IsUnique();
+
+ b.ToTable("MediaFiles");
+ });
+
+ modelBuilder.Entity("Commonage.Web.Domain.Tag", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("NormalizedName")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique();
+
+ b.ToTable("Tags");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("FriendlyName")
+ .HasColumnType("text");
+
+ b.Property("Xml")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("DataProtectionKeys");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("RoleId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("integer");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("PasswordHash")
+ .HasColumnType("text");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("text");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("text");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex");
+
+ b.ToTable("AspNetUsers", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("ProviderKey")
+ .HasColumnType("text");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.Property("RoleId")
+ .HasColumnType("text");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("ArchiveItemTag", b =>
+ {
+ b.HasOne("Commonage.Web.Domain.ArchiveItem", null)
+ .WithMany()
+ .HasForeignKey("ItemsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Commonage.Web.Domain.Tag", null)
+ .WithMany()
+ .HasForeignKey("TagsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Commonage.Web.Domain.ArchiveItem", b =>
+ {
+ b.HasOne("Commonage.Web.Domain.Collection", "Collection")
+ .WithMany("Items")
+ .HasForeignKey("CollectionId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Collection");
+ });
+
+ modelBuilder.Entity("Commonage.Web.Domain.AuditEntry", b =>
+ {
+ b.HasOne("Commonage.Web.Domain.ArchiveItem", "ArchiveItem")
+ .WithMany()
+ .HasForeignKey("ArchiveItemId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.Navigation("ArchiveItem");
+ });
+
+ modelBuilder.Entity("Commonage.Web.Domain.Contribution", b =>
+ {
+ b.HasOne("Commonage.Web.Domain.ArchiveItem", "ArchiveItem")
+ .WithOne("Contribution")
+ .HasForeignKey("Commonage.Web.Domain.Contribution", "ArchiveItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Commonage.Web.Domain.ContributionLink", "ContributionLink")
+ .WithMany("Contributions")
+ .HasForeignKey("ContributionLinkId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.Navigation("ArchiveItem");
+
+ b.Navigation("ContributionLink");
+ });
+
+ modelBuilder.Entity("Commonage.Web.Domain.ContributionLink", b =>
+ {
+ b.HasOne("Commonage.Web.Domain.Collection", "Collection")
+ .WithMany()
+ .HasForeignKey("CollectionId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Collection");
+ });
+
+ modelBuilder.Entity("Commonage.Web.Domain.MediaFile", b =>
+ {
+ b.HasOne("Commonage.Web.Domain.ArchiveItem", "ArchiveItem")
+ .WithMany("MediaFiles")
+ .HasForeignKey("ArchiveItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("ArchiveItem");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Commonage.Web.Domain.ArchiveItem", b =>
+ {
+ b.Navigation("Contribution");
+
+ b.Navigation("MediaFiles");
+ });
+
+ modelBuilder.Entity("Commonage.Web.Domain.Collection", b =>
+ {
+ b.Navigation("Items");
+ });
+
+ modelBuilder.Entity("Commonage.Web.Domain.ContributionLink", b =>
+ {
+ b.Navigation("Contributions");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Commonage.Web/Data/Migrations/20261106203813_AddReuseAndSharingConsent.cs b/src/Commonage.Web/Data/Migrations/20261106203813_AddReuseAndSharingConsent.cs
new file mode 100644
index 0000000..02a9e90
--- /dev/null
+++ b/src/Commonage.Web/Data/Migrations/20261106203813_AddReuseAndSharingConsent.cs
@@ -0,0 +1,39 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Commonage.Web.Data.Migrations
+{
+ ///
+ public partial class AddReuseAndSharingConsent : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "ConsentCoversSharing",
+ table: "Contributions",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.AddColumn(
+ name: "Reuse",
+ table: "Contributions",
+ type: "integer",
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "ConsentCoversSharing",
+ table: "Contributions");
+
+ migrationBuilder.DropColumn(
+ name: "Reuse",
+ table: "Contributions");
+ }
+ }
+}
diff --git a/src/Commonage.Web/Data/Migrations/CommonageDbContextModelSnapshot.cs b/src/Commonage.Web/Data/Migrations/CommonageDbContextModelSnapshot.cs
index 8a12057..84ef8e7 100644
--- a/src/Commonage.Web/Data/Migrations/CommonageDbContextModelSnapshot.cs
+++ b/src/Commonage.Web/Data/Migrations/CommonageDbContextModelSnapshot.cs
@@ -293,6 +293,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property("ArchiveItemId")
.HasColumnType("integer");
+ b.Property("ConsentCoversSharing")
+ .HasColumnType("boolean");
+
b.Property("ConsentText")
.IsRequired()
.HasMaxLength(4000)
@@ -316,6 +319,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasMaxLength(9)
.HasColumnType("character varying(9)");
+ b.Property("Reuse")
+ .HasColumnType("integer");
+
b.Property("ReviewedAt")
.HasColumnType("timestamp with time zone");
diff --git a/src/Commonage.Web/Demo/DemoContent.cs b/src/Commonage.Web/Demo/DemoContent.cs
index ccff146..8c7b37c 100644
--- a/src/Commonage.Web/Demo/DemoContent.cs
+++ b/src/Commonage.Web/Demo/DemoContent.cs
@@ -130,8 +130,9 @@ public static async Task SeedAsync(IServiceProvider services, IConfiguration con
ShowName = true,
Rights = RightsStatement.PermissionFromRightsHolder,
RightsHolder = T("Grandfather Example", "Großvater Beispiel"),
- ConsentText = T("I agree that the archive may keep what I send in, describe it and publish it on this website. If I chose so above, my name is shown with it. I can ask the archive to take it down at any time.",
- "Ich bin einverstanden, dass das Archiv meinen Beitrag aufbewahrt, beschreibt und auf dieser Website veröffentlicht. Wenn ich es oben gewählt habe, wird mein Name dabei angezeigt. Ich kann das Archiv jederzeit bitten, ihn wieder zu entfernen."),
+ ConsentText = T("I agree that the archive may keep what I send in, describe it and publish it on this website, also where a link to it is shared and in its list of what is new. If I chose so above, my name is shown with it. I can ask the archive to take it down at any time, but it cannot reach copies others have made.",
+ "Ich bin einverstanden, dass das Archiv meinen Beitrag aufbewahrt, beschreibt und auf dieser Website veröffentlicht, auch dort, wo ein Link darauf geteilt wird, und in seiner Liste der Neuigkeiten. Wenn ich es oben gewählt habe, wird mein Name dabei angezeigt. Ich kann das Archiv jederzeit bitten, ihn wieder zu entfernen, aber es kann Kopien nicht erreichen, die andere gemacht haben."),
+ ConsentCoversSharing = true,
SubmittedAt = now,
},
};
@@ -199,6 +200,7 @@ ArchiveItem Published(Collection collection, string title, string date, string d
ShowName = true,
Rights = RightsStatement.CreatedByContributor,
ConsentText = T("Agreed in conversation; recorded by a curator.", "Im Gespräch zugestimmt; von der Kuratorin erfasst."),
+ ConsentCoversSharing = true,
SubmittedAt = now,
};
}
diff --git a/src/Commonage.Web/Domain/ArchiveItem.cs b/src/Commonage.Web/Domain/ArchiveItem.cs
index a3e87d8..0962f5c 100644
--- a/src/Commonage.Web/Domain/ArchiveItem.cs
+++ b/src/Commonage.Web/Domain/ArchiveItem.cs
@@ -71,6 +71,9 @@ public class ArchiveItem
///
public bool NeedsTranscription { get; set; }
+ /// Whether its picture and text may go along when a link is shared or in a feed. Needs to be loaded.
+ public bool MayTravel => Contribution is null || Contribution.ConsentCoversSharing;
+
public void MoveToBin(DateTime now) => DeletedAt = now;
public void RestoreFromBin() => DeletedAt = null;
diff --git a/src/Commonage.Web/Domain/Contribution.cs b/src/Commonage.Web/Domain/Contribution.cs
index e705cc4..24da3ee 100644
--- a/src/Commonage.Web/Domain/Contribution.cs
+++ b/src/Commonage.Web/Domain/Contribution.cs
@@ -11,6 +11,13 @@ public enum RightsStatement
Unknown = 2,
}
+/// What a contributor offered others, if anything. Only a suggestion for curators, who decide what visitors are told.
+public enum ReuseOffer
+{
+ NonCommercialWithCredit = 1,
+ AnyUseWithCredit = 2,
+}
+
///
/// Who sent in an archive item through a contribution link, and what they agreed to. Only curators see it,
/// except the contributor's name on the public page when is set.
@@ -64,9 +71,18 @@ public static string NewReferenceCode()
/// Who took the photos or wrote the text, if the contributor knows and it is not them.
public string? RightsHolder { get; set; }
- /// The consent statement exactly as the contributor saw and accepted it, in their language.
+ /// The consent statement exactly as the contributor saw and accepted it, in their language, with any reuse they offered.
public required string ConsentText { get; set; }
+ /// What they offered others. Null means others have to ask.
+ public ReuseOffer? Reuse { get; set; }
+
+ ///
+ /// They agreed to what travels beyond this website: link previews and the feed. Earlier contributions agreed to
+ /// "this website" and stay under what they agreed to, so they stay out of those.
+ ///
+ public bool ConsentCoversSharing { get; set; }
+
public DateTime SubmittedAt { get; set; }
/// When a curator first published, accepted or rejected the contribution.
diff --git a/src/Commonage.Web/Domain/RightsOptions.cs b/src/Commonage.Web/Domain/RightsOptions.cs
index 925a564..760f060 100644
--- a/src/Commonage.Web/Domain/RightsOptions.cs
+++ b/src/Commonage.Web/Domain/RightsOptions.cs
@@ -29,6 +29,10 @@ public static class RightsOptions
public static bool IsKnown(string uri) => All.Contains(uri);
+ /// What a visitor is told, also when nobody has decided anything yet.
+ public static string LabelOrAsk(IStringLocalizer localizer, string? uri) =>
+ uri is null ? localizer["Nothing has been decided yet: please ask the archive before reusing it"] : Label(localizer, uri);
+
public static string Label(IStringLocalizer localizer, string uri) => uri switch
{
InCopyright => localizer["In copyright: ask before reusing"],
@@ -46,11 +50,13 @@ public static class RightsOptions
///
/// A first suggestion from what a contributor answered. Even someone who made a photo has not given it away by sending
- /// it in, so the suggestion is never an open licence: the contributor agreed to publication on this website, no more.
+ /// it in, so the suggestion is only an open licence if they offered one themselves, and never when they do not know who made it.
///
- public static string ProposalFor(RightsStatement answer) => answer switch
+ public static string ProposalFor(RightsStatement answer, ReuseOffer? offer = null) => (answer, offer) switch
{
- RightsStatement.Unknown => NotEvaluated,
+ (RightsStatement.Unknown, _) => NotEvaluated,
+ (_, ReuseOffer.AnyUseWithCredit) => CcBy,
+ (_, ReuseOffer.NonCommercialWithCredit) => CcByNc,
_ => InCopyright,
};
}
diff --git a/src/Commonage.Web/Export/ArchiveExporter.cs b/src/Commonage.Web/Export/ArchiveExporter.cs
index 7353d24..add16ff 100644
--- a/src/Commonage.Web/Export/ArchiveExporter.cs
+++ b/src/Commonage.Web/Export/ArchiveExporter.cs
@@ -172,6 +172,8 @@ private static ExportFile Describe(MediaFile file, string? path, string? problem
contribution.Rights,
options.IncludePersonalData ? contribution.RightsHolder : null,
contribution.ConsentText,
+ contribution.Reuse,
+ contribution.ConsentCoversSharing,
contribution.ReferenceCode,
contribution.SubmittedAt,
contribution.ReviewedAt,
@@ -288,6 +290,8 @@ internal record ExportContribution(
RightsStatement Rights,
string? RightsHolder,
string ConsentText,
+ ReuseOffer? Reuse,
+ bool ConsentCoversSharing,
string? ReferenceCode,
DateTime SubmittedAt,
DateTime? ReviewedAt,
diff --git a/src/Commonage.Web/Operations/Branding.cs b/src/Commonage.Web/Operations/Branding.cs
index b15aae7..101c69a 100644
--- a/src/Commonage.Web/Operations/Branding.cs
+++ b/src/Commonage.Web/Operations/Branding.cs
@@ -1,4 +1,5 @@
using Commonage.Web.Media;
+using Commonage.Web.Search;
using Microsoft.Extensions.Options;
using NetVips;
@@ -87,9 +88,9 @@ public static void MapBrandingEndpoints(this WebApplication app)
return Results.Stream(storage.OpenRead(LogoImages.KeyFor(id, variant)), "image/png");
});
- app.MapGet("/robots.txt", async (ArchiveProfile profile) =>
+ app.MapGet("/robots.txt", async (ArchiveProfile profile, HttpContext context) =>
Results.Text((await profile.GetAsync()).AllowSearchEngines
- ? "User-agent: *\nDisallow: /Admin\nDisallow: /Account\nDisallow: /contribute\n"
+ ? $"User-agent: *\nDisallow: /Admin\nDisallow: /Account\nDisallow: /contribute\n\nSitemap: {context.Request.BaseAddress()}{PublicFeeds.SitemapPath}\n"
: "User-agent: *\nDisallow: /\n", "text/plain"));
}
}
diff --git a/src/Commonage.Web/Pages/Admin/Items/Edit.cshtml b/src/Commonage.Web/Pages/Admin/Items/Edit.cshtml
index 93fc721..3736fa8 100644
--- a/src/Commonage.Web/Pages/Admin/Items/Edit.cshtml
+++ b/src/Commonage.Web/Pages/Admin/Items/Edit.cshtml
@@ -89,6 +89,15 @@
@T["Photographer or author: {0}", contribution.RightsHolder]
}
+
@T["May others reuse it?"]
+
+ @(contribution.Reuse switch
+ {
+ ReuseOffer.AnyUseWithCredit => T["Yes, for any purpose, with credit"],
+ ReuseOffer.NonCommercialWithCredit => T["Yes, for non-commercial purposes, with credit"],
+ _ => T["Not offered: others should ask first"],
+ })
+
- @if (Model.RightsWereProposed)
+ @if (Model.RightsWereProposed && Model.Contribution?.Reuse is not null)
+ {
+ @T["Suggested because the contributor offered this themselves. Check that it is theirs to give before you publish: a free licence cannot be taken back from those who have already reused it."]
+ }
+ else if (Model.RightsWereProposed)
{
@T["Suggested from what the contributor answered. They agreed to publication on this website, not to reuse by others, so only a free licence they gave you themselves should go further."]
}
diff --git a/src/Commonage.Web/Pages/Collections/Details.cshtml b/src/Commonage.Web/Pages/Collections/Details.cshtml
index 02ec7d3..ecd1b4e 100644
--- a/src/Commonage.Web/Pages/Collections/Details.cshtml
+++ b/src/Commonage.Web/Pages/Collections/Details.cshtml
@@ -2,6 +2,9 @@
@model Commonage.Web.Pages.Collections.DetailsModel
@{
ViewData["Title"] = Model.Collection.Title;
+ var address = $"{HttpContext.Request.BaseAddress()}/collections/{Model.Collection.Id}";
+ ViewData["Canonical"] = address;
+ ViewData["LinkPreview"] = new LinkPreview(Model.Collection.Title, LinkPreview.Shorten(Model.Collection.Description), address);
}
@@ -83,6 +83,16 @@
+ @* With JavaScript, only there when it can apply. Without, the archive ignores it if they do not know who made it. *@
+
+
+
+
@T["Choose yes only if this is yours to give. It cannot be taken back from those who have already reused it."]
+
+ [Fact]
+ public async Task Behind_the_proxy_every_address_says_https()
+ {
+ var item = await PublishedAsync("Behind the proxy");
+ await AddPhotoAsync(item.Id);
+ await using var production = app.WithWebHostBuilder(b => b.UseSetting("FORWARDEDHEADERS_ENABLED", "true"));
+ using var visitor = production.CreateClient();
+ visitor.DefaultRequestHeaders.Add("X-Forwarded-Proto", "https");
+
+ var page = await visitor.GetStringAsync($"/items/{item.Id}");
+ var feed = await visitor.GetStringAsync("/feed.xml");
+ var sitemap = await visitor.GetStringAsync("/sitemap.xml");
+ var robots = await visitor.GetStringAsync("/robots.txt");
+
+ Assert.Contains($"http://", sitemap);
+ Assert.Contains("Sitemap: https://localhost/sitemap.xml", robots);
+ }
+
+ [Fact]
+ public async Task A_link_shows_the_picture_title_and_the_beginning_of_the_text()
+ {
+ var longText = string.Join(' ', Enumerable.Repeat("Mill street in the morning light", 20));
+ var item = await PublishedAsync("Morning in Mill Street", i => i.Description = longText);
+ await AddPhotoAsync(item.Id);
+ using var visitor = app.CreateClient();
+ var photo = await app.WithDbAsync(db => db.MediaFiles.Where(f => f.ArchiveItemId == item.Id).Select(f => f.Id).SingleAsync());
+
+ var page = await visitor.GetStringAsync($"/items/{item.Id}");
+
+ Assert.Equal("Morning in Mill Street", Meta(page, "og:title"));
+ Assert.Equal("article", Meta(page, "og:type"));
+ Assert.Equal($"http://localhost/media/{photo}/card", Meta(page, "og:image"));
+ Assert.Equal("Morning in Mill Street", Meta(page, "og:image:alt"));
+ var description = Meta(page, "og:description")!;
+ Assert.True(description.Length <= 201, $"{description.Length} characters.");
+ Assert.EndsWith("…", description);
+ Assert.Equal(HttpStatusCode.OK, (await visitor.GetAsync(new Uri(Meta(page, "og:image")!).AbsolutePath)).StatusCode);
+ }
+
+ [Fact]
+ public async Task A_contribution_from_before_only_agreed_to_this_website_so_nothing_of_it_travels()
+ {
+ var earlier = await PublishedAsync("Sent in earlier", i => i.Description = "Words that were agreed for this website", From(consentCoversSharing: false));
+ var later = await PublishedAsync("Sent in later", i => i.Description = "Words that may travel", From(consentCoversSharing: true));
+ await AddPhotoAsync(earlier.Id);
+ await AddPhotoAsync(later.Id);
+ using var visitor = app.CreateClient();
+
+ var earlierPage = await visitor.GetStringAsync($"/items/{earlier.Id}");
+ var laterPage = await visitor.GetStringAsync($"/items/{later.Id}");
+ var feed = await FeedAsync();
+
+ // The page itself is what they agreed to.
+ Assert.Contains("Words that were agreed for this website", earlierPage);
+ Assert.Contains($"/media/", earlierPage);
+ Assert.Null(Meta(earlierPage, "og:description"));
+ Assert.Null(Meta(earlierPage, "og:image"));
+ Assert.Equal("Sent in earlier", Meta(earlierPage, "og:title"));
+ Assert.Null(EntryOf(feed, earlier.Id));
+
+ Assert.Equal("Words that may travel", Meta(laterPage, "og:description"));
+ Assert.NotNull(Meta(laterPage, "og:image"));
+ Assert.NotNull(EntryOf(feed, later.Id));
+ }
+
+ [Fact]
+ public async Task The_feed_holds_what_is_public_and_survives_any_text()
+ {
+ var item = await PublishedAsync("Fish & \u0001in the \"market\"", i =>
+ {
+ i.Description = "A line with \u0008 a control character & markup";
+ i.RightsUri = RightsOptions.CcBy;
+ i.Credit = "Photo: ";
+ }, From(consentCoversSharing: true));
+ await AddPhotoAsync(item.Id);
+ var hidden = await PublishedAsync("Not public any more");
+ await app.WithDbAsync(async db =>
+ {
+ (await db.ArchiveItems.SingleAsync(i => i.Id == hidden.Id)).Unpublish();
+ return await db.SaveChangesAsync();
+ });
+
+ var feed = await FeedAsync();
+
+ var entry = EntryOf(feed, item.Id);
+ Assert.NotNull(entry);
+ Assert.Equal("Fish & in the \"market\"", entry.Element(Atom + "title")!.Value);
+ Assert.Equal("Free to use with credit (CC BY)", entry.Element(Atom + "rights")!.Value);
+ var content = entry.Element(Atom + "content")!.Value;
+ Assert.Contains("<b>markup</b>", content);
+ Assert.Contains("/media/", content);
+ Assert.Contains("Photo: <Anna>", content);
+ Assert.DoesNotContain("Private Person", feed.ToString());
+ Assert.DoesNotContain("private-person@example.org", feed.ToString());
+ Assert.Null(EntryOf(feed, hidden.Id));
+ Assert.True(feed.Elements(Atom + "entry").Count() <= 30);
+ Assert.NotNull(feed.Element(Atom + "updated"));
+ Assert.NotNull(feed.Element(Atom + "author"));
+ }
+
+ [Fact]
+ public async Task The_sitemap_lists_what_is_public_and_nothing_else()
+ {
+ var item = await PublishedAsync("In the sitemap");
+ var earlier = await PublishedAsync("From a contribution before", contribution: From(consentCoversSharing: false));
+ var hidden = await PublishedAsync("Not in the sitemap");
+ await app.WithDbAsync(async db =>
+ {
+ (await db.ArchiveItems.SingleAsync(i => i.Id == hidden.Id)).Unpublish();
+ return await db.SaveChangesAsync();
+ });
+ using var visitor = app.CreateClient();
+
+ var sitemap = XDocument.Parse(await visitor.GetStringAsync("/sitemap.xml")).Root!;
+ var addresses = sitemap.Elements(Sitemap + "url").Select(u => u.Element(Sitemap + "loc")!.Value).ToList();
+
+ Assert.Contains($"http://localhost/items/{item.Id}", addresses);
+ // A sitemap only says what is on this website already.
+ Assert.Contains($"http://localhost/items/{earlier.Id}", addresses);
+ Assert.Contains($"http://localhost/collections/{item.CollectionId}", addresses);
+ Assert.Contains("http://localhost/", addresses);
+ Assert.DoesNotContain($"http://localhost/items/{hidden.Id}", addresses);
+ Assert.DoesNotContain($"http://localhost/collections/{hidden.CollectionId}", addresses);
+ Assert.Contains("Sitemap: http://localhost/sitemap.xml", await visitor.GetStringAsync("/robots.txt"));
+ }
+
+ [Fact]
+ public async Task All_of_it_follows_the_switch_for_search_engines()
+ {
+ var item = await PublishedAsync("Off the record", i => i.Description = "Some text");
+ using var visitor = app.CreateClient();
+
+ await WithSettingsAsync(settings => settings.AllowSearchEngines = false, async () =>
+ {
+ var page = await visitor.GetStringAsync($"/items/{item.Id}");
+
+ Assert.Equal(HttpStatusCode.NotFound, (await visitor.GetAsync("/feed.xml")).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await visitor.GetAsync("/sitemap.xml")).StatusCode);
+ Assert.DoesNotContain("Sitemap:", await visitor.GetStringAsync("/robots.txt"));
+ Assert.Null(Meta(page, "og:title"));
+ Assert.DoesNotContain("application/atom+xml", page);
+ Assert.Contains("noindex", page);
+ // The page still says what it is, and how to cite it.
+ Assert.Contains("rel=\"canonical\"", page);
+ Assert.Contains("id=\"citation\"", page);
+ });
+ }
+
+ [Fact]
+ public async Task Pages_for_curators_and_contributors_show_nothing_when_a_link_to_them_is_shared()
+ {
+ using var visitor = app.CreateClient();
+ var token = await app.WithDbAsync(async db =>
+ {
+ var link = new ContributionLink { Title = "Send it in", Collection = new Collection { Title = $"Collection {Guid.NewGuid():N}", CreatedAt = DateTime.UtcNow }, Token = ContributionLink.NewToken(), IsActive = true, CreatedAt = DateTime.UtcNow };
+ db.Add(link);
+ await db.SaveChangesAsync();
+ return link.Token;
+ });
+
+ Assert.Null(Meta(await visitor.GetStringAsync($"/contribute/{token}"), "og:title"));
+ Assert.Null(Meta(await visitor.GetStringAsync("/Account/Login"), "og:title"));
+ }
+
+ [Theory]
+ [InlineData("CreatedByContributor", "AnyUseWithCredit", "AnyUseWithCredit", "reuse it for any purpose")]
+ [InlineData("PermissionFromRightsHolder", "NonCommercialWithCredit", "NonCommercialWithCredit", "non-commercial purposes")]
+ [InlineData("Unknown", "AnyUseWithCredit", null, null)]
+ [InlineData("CreatedByContributor", "", null, null)]
+ public async Task A_contributor_can_offer_reuse_but_not_what_they_do_not_know_to_be_theirs(string rights, string offer, string? stored, string? inConsent)
+ {
+ var token = await app.WithDbAsync(async db =>
+ {
+ var link = new ContributionLink { Title = "Send it in", Collection = new Collection { Title = $"Collection {Guid.NewGuid():N}", CreatedAt = DateTime.UtcNow }, Token = ContributionLink.NewToken(), IsActive = true, CreatedAt = DateTime.UtcNow };
+ db.Add(link);
+ await db.SaveChangesAsync();
+ return link.Token;
+ });
+ using var visitor = app.CreateClient();
+
+ await Browser.SendFormWithFilesAsync(visitor, $"/contribute/{token}", new()
+ {
+ ["Input.Title"] = "With an offer",
+ ["Input.Name"] = "Ada Contributor",
+ ["Input.Rights"] = rights,
+ ["Input.Reuse"] = offer,
+ ["Input.Consent"] = "true",
+ });
+
+ var contribution = await app.WithDbAsync(db => db.Contributions.Include(c => c.ArchiveItem).SingleAsync(c => c.ContributionLink!.Token == token));
+ Assert.Equal(stored, contribution.Reuse?.ToString());
+ Assert.True(contribution.ConsentCoversSharing);
+ Assert.Contains("also where a link to it is shared", contribution.ConsentText);
+ if (inConsent is null)
+ Assert.DoesNotContain("I also allow", contribution.ConsentText);
+ else
+ Assert.Contains(inConsent, contribution.ConsentText);
+ // Nothing is decided by it: visitors are told nothing until a curator publishes.
+ Assert.Null(contribution.ArchiveItem.RightsUri);
+ Assert.Equal(ItemStatus.Submitted, contribution.ArchiveItem.Status);
+ }
+
+ [Fact]
+ public async Task A_curator_sees_what_was_offered_as_a_suggestion_and_has_to_decide()
+ {
+ var offered = await app.WithDbAsync(async db =>
+ {
+ var item = new ArchiveItem
+ {
+ Title = "Offered freely",
+ Collection = new Collection { Title = $"Collection {Guid.NewGuid():N}", CreatedAt = DateTime.UtcNow },
+ Status = ItemStatus.Submitted,
+ CreatedAt = DateTime.UtcNow,
+ UpdatedAt = DateTime.UtcNow,
+ Contribution = new Contribution
+ {
+ ContributorName = "Ada",
+ Rights = RightsStatement.CreatedByContributor,
+ Reuse = ReuseOffer.AnyUseWithCredit,
+ ConsentText = "I agree.",
+ SubmittedAt = DateTime.UtcNow,
+ },
+ };
+ db.Add(item);
+ await db.SaveChangesAsync();
+ return item;
+ });
+ using var curator = app.CreateClient();
+ await Browser.LogInAsCuratorAsync(curator);
+
+ var page = await curator.GetStringAsync($"/Admin/Items/Edit/{offered.Id}");
+
+ Assert.Contains("Yes, for any purpose, with credit", page);
+ Assert.Contains("Suggested because the contributor offered this themselves", page);
+ Assert.Matches($"""