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"], + }) +
@T["Sent in"]
@Dates.Moment(contribution.SubmittedAt) diff --git a/src/Commonage.Web/Pages/Admin/Items/Edit.cshtml.cs b/src/Commonage.Web/Pages/Admin/Items/Edit.cshtml.cs index a94b784..0debd99 100644 --- a/src/Commonage.Web/Pages/Admin/Items/Edit.cshtml.cs +++ b/src/Commonage.Web/Pages/Admin/Items/Edit.cshtml.cs @@ -186,7 +186,7 @@ public async Task OnGetAsync(int? id, int? collectionId) Date = item.DateText, Tags = string.Join(", ", item.Tags.Select(t => t.Name).Order(StringComparer.CurrentCultureIgnoreCase)), Version = item.Version, - RightsUri = item.RightsUri ?? (item.Contribution is { } contribution ? RightsOptions.ProposalFor(contribution.Rights) : null), + RightsUri = item.RightsUri ?? (item.Contribution is { } contribution ? RightsOptions.ProposalFor(contribution.Rights, contribution.Reuse) : null), RightsHolder = item.RightsHolder, Credit = item.Credit, ReferenceNumber = item.ReferenceNumber, diff --git a/src/Commonage.Web/Pages/Admin/Items/_Catalogue.cshtml b/src/Commonage.Web/Pages/Admin/Items/_Catalogue.cshtml index 9a7d93d..b8038ee 100644 --- a/src/Commonage.Web/Pages/Admin/Items/_Catalogue.cshtml +++ b/src/Commonage.Web/Pages/Admin/Items/_Catalogue.cshtml @@ -14,7 +14,11 @@
- @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."]
+
@@ -96,7 +106,7 @@ -
@T["Only the archive's curators see your name and email address, unless you choose to have your name shown. Your email address is only used to ask about what you send in."]
+
@T["Only curators see these, unless you choose to show your name. Your email address is only for questions about what you send in."]
diff --git a/src/Commonage.Web/Pages/Contribute/Index.cshtml.cs b/src/Commonage.Web/Pages/Contribute/Index.cshtml.cs index b219df3..bf0fe66 100644 --- a/src/Commonage.Web/Pages/Contribute/Index.cshtml.cs +++ b/src/Commonage.Web/Pages/Contribute/Index.cshtml.cs @@ -102,6 +102,9 @@ public class InputModel [Display(Name = "Name of the photographer or author, if known")] public string? RightsHolder { get; set; } + [Display(Name = "May others reuse it? (optional)")] + public ReuseOffer? Reuse { get; set; } + public bool Consent { get; set; } } @@ -166,10 +169,13 @@ public async Task OnPostAsync(string token, CancellationToken can return Page(); } - var fingerprint = SendingFingerprints.Of(SendingKey, link.Id, [Input.Title, Input.Date, Input.Description, Input.Rights.ToString(), Input.RightsHolder], Files); + var fingerprint = SendingFingerprints.Of(SendingKey, link.Id, [Input.Title, Input.Date, Input.Description, Input.Rights.ToString(), Input.RightsHolder, Input.Reuse.ToString()], Files); if (await AlreadySentAsync(fingerprint) is { } reference) return Sent(token, reference); + // Nobody can offer what they do not know to be theirs. + var reuse = Input.Rights == RightsStatement.Unknown ? null : Input.Reuse; + var now = DateTime.UtcNow; var item = new ArchiveItem { @@ -187,7 +193,14 @@ public async Task OnPostAsync(string token, CancellationToken can ShowName = Input.ShowName, Rights = Input.Rights!.Value, RightsHolder = string.IsNullOrWhiteSpace(Input.RightsHolder) ? null : Input.RightsHolder.Trim(), - ConsentText = ConsentStatement, + ConsentText = reuse switch + { + ReuseOffer.AnyUseWithCredit => $"{ConsentStatement} {localizer["I also allow anyone to reuse it for any purpose, with credit (CC BY 4.0)."]}", + ReuseOffer.NonCommercialWithCredit => $"{ConsentStatement} {localizer["I also allow anyone to reuse it for non-commercial purposes, with credit (CC BY-NC 4.0)."]}", + _ => ConsentStatement, + }, + Reuse = reuse, + ConsentCoversSharing = true, ReferenceCode = Contribution.NewReferenceCode(), SendingFingerprint = fingerprint, SubmittedAt = now, @@ -250,7 +263,7 @@ fingerprint is null /// Shown next to the consent checkbox and stored with the contribution, in the contributor's language. public string ConsentStatement => localizer[ - "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."]; + "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."]; /// Counts what is in the bin too, because it still takes up disk space. private async Task WouldExceedDailyLimitAsync(ContributionLink link, CancellationToken cancellationToken) diff --git a/src/Commonage.Web/Pages/Items/Details.cshtml b/src/Commonage.Web/Pages/Items/Details.cshtml index 9a4a228..04b3ff8 100644 --- a/src/Commonage.Web/Pages/Items/Details.cshtml +++ b/src/Commonage.Web/Pages/Items/Details.cshtml @@ -2,6 +2,9 @@ @model Commonage.Web.Pages.Items.DetailsModel @{ ViewData["Title"] = Model.Item.Title; + ViewData["Canonical"] = Model.Address; + ViewData["Citation"] = Model.Citation; + ViewData["LinkPreview"] = Model.Preview; }
diff --git a/src/Commonage.Web/Pages/Shared/_Layout.cshtml b/src/Commonage.Web/Pages/Shared/_Layout.cshtml index aa1b52a..4d268ad 100644 --- a/src/Commonage.Web/Pages/Shared/_Layout.cshtml +++ b/src/Commonage.Web/Pages/Shared/_Layout.cshtml @@ -21,6 +21,36 @@ { } + @if (ViewData["Canonical"] is string canonical) + { + + } + @if (!isPrivatePage && identity.AllowSearchEngines) + { + var address = Context.Request.BaseAddress(); + var preview = ViewData["LinkPreview"] as Commonage.Web.Search.LinkPreview + ?? new Commonage.Web.Search.LinkPreview(pageTitle ?? identity.Name, identity.Text(ArchiveTextKind.Tagline), $"{address}{Context.Request.Path}"); + var image = preview.ImageUrl ?? (identity.LogoId is { } logo ? $"{address}/branding/{logo}/icon" : null); + + + + + + @if (preview.Description is not null) + { + + + } + @if (image is not null) + { + + if (preview.ImageAlt is not null) + { + + } + + } + } @if (identity.LogoId is { } iconId) { diff --git a/src/Commonage.Web/Program.cs b/src/Commonage.Web/Program.cs index a8a237c..288b96f 100644 --- a/src/Commonage.Web/Program.cs +++ b/src/Commonage.Web/Program.cs @@ -11,6 +11,7 @@ using Commonage.Web.Identity; using Commonage.Web.Media; using Commonage.Web.Operations; +using Commonage.Web.Search; using Commonage.Web.Security; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; @@ -168,6 +169,7 @@ app.MapHealthChecks("/health"); app.MapMediaEndpoints(); app.MapDateCheck(); +app.MapPublicFeeds(); app.MapBrandingEndpoints(); app.MapStaticAssets(); app.MapRazorPages() diff --git a/src/Commonage.Web/Resources/SharedResource.de.resx b/src/Commonage.Web/Resources/SharedResource.de.resx index 097a139..dc7eff2 100644 --- a/src/Commonage.Web/Resources/SharedResource.de.resx +++ b/src/Commonage.Web/Resources/SharedResource.de.resx @@ -25,6 +25,7 @@ Als Entwurf übernehmen Beitrag als Entwurf übernommen Zugang + abgerufen am {0} Konto Konten Aktiv @@ -50,7 +51,6 @@ Egal Filter anwenden Archiv-Einstellungen - So genau, wie Sie es wissen, zum Beispiel: 1953, März 1953, um 1950, 1950er Jahre. Mindestens {0} Zeichen. Ein paar Wörter hintereinander sind leicht zu merken und schwer zu erraten. Automatisch Zurück zum Bearbeiten @@ -79,8 +79,10 @@ Sammlung auswählen Logo auswählen Fotos auswählen + Wählen Sie Ja nur, wenn Sie das erlauben dürfen. Wer es schon weiterverwendet hat, behält dieses Recht. Wählen Sie Ihr Passwort Auswählen: {0} + Diesen Eintrag zitieren Filter zurücksetzen Diesen Entwurf löschen Geschlossen @@ -103,6 +105,8 @@ Einsendende Person Einsendende Einsendenden wird zugesagt, dass sie um Entfernung bitten können. Sagen Sie, wie: eine E-Mail-Adresse, eine Telefonnummer oder ein Treffpunkt. + Kopiert + Kopieren Diese Angaben korrigieren Hat die Angaben der einsendenden Person korrigiert Titelbild @@ -173,6 +177,7 @@ Fertig: {0} von {1} Bei einem Brief oder anderem Geschriebenen schreiben Sie hier ab, was dort steht. Handschrift lässt sich nicht durchsuchen oder vorlesen, dieser Text schon. Für alle in dieser Liste: {0} + Zum Beispiel: 1953, März 1953, um 1950 oder 1950er Jahre. Für Menschen, die das Foto nicht sehen können: wer und was darauf ist, in einem Satz. Wird zusammen mit dem Eintrag gespeichert. Für das Konto {0}. Verwenden Sie mindestens {1} Zeichen. Ein paar Wörter hintereinander sind leicht zu merken und schwer zu erraten. Für die angekreuzten Einträge @@ -196,7 +201,9 @@ Handbuch Verlauf So erreichen Sie das Archiv - 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. + 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. + Außerdem erlaube ich allen, es mit Namensnennung für jeden Zweck weiterzuverwenden (CC BY 4.0). + Außerdem erlaube ich allen, es mit Namensnennung für nicht kommerzielle Zwecke weiterzuverwenden (CC BY-NC 4.0). Ich weiß nicht, wer es gemacht hat. Ich habe es selbst gemacht, zum Beispiel die Fotos selbst aufgenommen. Wenn die einsendende Person darum bittet @@ -233,6 +240,7 @@ Ausgelassen, weil sie noch keinen Titel haben: {0}. Suchmaschinen dürfen dieses Archiv aufführen Besucher können Fotos in großer Größe öffnen + Link kopiert Anmelden Abmelden Logo @@ -244,6 +252,9 @@ Beitrag als zurückgezogen markiert Darf auf öffentlichen Seiten genannt werden darf öffentlich genannt werden + Darf ich das weiterverwenden? + Dürfen andere es weiterverwenden? + Dürfen andere es weiterverwenden? (optional) Mehr aus {0} Viele in eine Sammlung verschieben Diesen Eintrag in den Papierkorb legen? Er verschwindet sofort aus dem Archiv. Sie können ihn dort {0} Tage lang wiederherstellen. @@ -278,20 +289,23 @@ Keine Fotos ausgewählt Es konnte keine Vorschau erstellt werden. Keine Rolle + Nein: Sie sollen zuerst fragen Noch nicht geprüft: vor einer Weiterverwendung fragen Noch nicht entschieden + Nicht angeboten: Andere sollen zuerst fragen soll nicht öffentlich genannt werden Hat vermerkt, dass eine Wiederherstellung getestet wurde + Noch nicht entschieden: Bitte fragen Sie das Archiv, bevor Sie es weiterverwenden Bisher wurde nichts aufgezeichnet. Nichts gefunden. Versuchen Sie es mit weniger Wörtern oder entfernen Sie einen Filter. Älteste zuerst Bei jedem Beitrag können Sie Name und E-Mail-Adresse entfernen, ihn als zurückgezogen markieren oder den Eintrag in den Papierkorb legen und dort endgültig löschen. Sicherungen enthalten ältere Kopien, bis sie ersetzt werden. Im Einsendeformular bleiben Titel, Datum und Beschreibung, die Sie eingeben, drei Tage oder bis zum Absenden in Ihrem eigenen Browser, damit nichts verloren geht, wenn Sie unterbrechen müssen. Das Archiv erhält sie erst, wenn Sie das Formular senden, und Sie können sie jederzeit löschen. + Nur die Kuratoren sehen diese Angaben, es sei denn, Sie möchten Ihren Namen zeigen. Ihre E-Mail-Adresse dient nur für Rückfragen zu Ihrer Einsendung. Das sehen nur Kuratoren. Es bleibt aus öffentlichen Seiten und der öffentlichen Suche heraus. Das sehen nur Kuratoren. Nur für eine vollständige Kopie, etwa für einen Umzug auf einen anderen Server. Bewahren Sie eine solche Datei so sorgfältig auf wie das Archiv selbst. Nur veröffentlichte Einträge - Nur die Kuratoren des Archivs sehen Ihren Namen und Ihre E-Mail-Adresse, es sei denn, Sie möchten, dass Ihr Name genannt wird. Ihre E-Mail-Adresse wird nur für Rückfragen zu Ihrer Einsendung verwendet. Offen In voller Größe öffnen Offen: Über den Link können Beiträge eingesendet werden @@ -414,6 +428,7 @@ Nächtliche Sicherungen einrichten (docs/deployment.md) Einstellungen Einrichtungslink + Teilen Meinen Namen bei meinem Beitrag anzeigen Angezeigt werden die neuesten {0}. Zum Inhalt springen @@ -429,6 +444,7 @@ Muss noch abgeschrieben werden Senden abbrechen Eingesendet + Vorgeschlagen, weil die einsendende Person das selbst angeboten hat. Prüfen Sie vor dem Veröffentlichen, ob sie das erlauben darf: Eine freie Lizenz lässt sich gegenüber denen, die schon weiterverwendet haben, nicht zurücknehmen. Vorschlag aus der Antwort der einsendenden Person. Sie hat der Veröffentlichung auf dieser Website zugestimmt, nicht der Weiterverwendung durch andere. Darüber hinaus sollte nur eine freie Lizenz gehen, die sie Ihnen selbst gegeben hat. Schlagwort Schlagwort: {0} @@ -561,9 +577,11 @@ Was Was wissen Sie darüber? (optional) Was zeigt es? + Neues in {0} Was dürfen andere damit tun? Was dürfen andere mit Ihren Einträgen tun, wenn ein Eintrag nichts anderes sagt? Was andere mit diesem Eintrag tun dürfen. Wählen Sie eine freie Lizenz nur, wenn sie wirklich von der Person stammt, die die Rechte hat. + Was das bedeutet Wonach Sie suchen und wer Sie sind. Was Sie senden Wann @@ -581,6 +599,8 @@ Ohne das werden Suchmaschinen gebeten, das Archiv auszulassen. Wer einen Link hat, kann es trotzdem öffnen. Ein Impressum schreiben oder vermerken, dass Sie keines brauchen Geben Sie das Datum so genau an, wie es bekannt ist, zum Beispiel: 1953, 12.03.1953, März 1953, ca. 1950, 1950er Jahre, 1950–1955, vor 1945. + Ja, für jeden Zweck, mit Namensnennung + Ja, für nicht kommerzielle Zwecke, mit Namensnennung Sie sind nicht mehr angemeldet. Melden Sie sich in einem anderen Tab wieder an und machen Sie dann weiter. Sie können höchstens {0} Fotos auf einmal senden. Ihr Konto kann diese Seite nicht öffnen. Fragen Sie einen Admin, wenn Sie sie brauchen. diff --git a/src/Commonage.Web/Search/PublicFeeds.cs b/src/Commonage.Web/Search/PublicFeeds.cs new file mode 100644 index 0000000..a3309c0 --- /dev/null +++ b/src/Commonage.Web/Search/PublicFeeds.cs @@ -0,0 +1,154 @@ +using System.Globalization; +using System.Net; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using Commonage.Web.Data; +using Commonage.Web.Domain; +using Commonage.Web.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Localization; + +namespace Commonage.Web.Search; + +/// What a link shows when it is pasted somewhere, such as a messenger, and what a page is really called. +public record LinkPreview(string Title, string? Description, string Url, string? ImageUrl = null, string? ImageAlt = null, string Type = "website") +{ + private const int MaxDescriptionLength = 200; + + /// The beginning of a text, cut between words. A letter's first lines say more than none. + public static string? Shorten(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return null; + var oneLine = string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + if (oneLine.Length <= MaxDescriptionLength) + return oneLine; + var end = oneLine.LastIndexOf(' ', MaxDescriptionLength); + return $"{oneLine[..(end > 0 ? end : MaxDescriptionLength)]}…"; + } +} + +/// +/// A feed of what is new and a list of all public pages. Both exist for programs that find an archive, so both follow the switch +/// for search engines. Neither says anything a public page does not say. +/// +public static class PublicFeeds +{ + public const string FeedPath = "/feed.xml"; + public const string SitemapPath = "/sitemap.xml"; + + private const int FeedLength = 30; + + // A sitemap may hold at most 50,000 addresses. + private const int MaxSitemapItems = 49_000; + private const string CacheControl = "public, max-age=600"; + + private static readonly XNamespace Atom = "http://www.w3.org/2005/Atom"; + private static readonly XNamespace Sitemap = "http://www.sitemaps.org/schemas/sitemap/0.9"; + + public static void MapPublicFeeds(this WebApplication app) + { + app.MapGet(FeedPath, ServeFeedAsync); + app.MapGet(SitemapPath, ServeSitemapAsync); + } + + public static string BaseAddress(this HttpRequest request) => $"{request.Scheme}://{request.Host}"; + + private static async Task ServeFeedAsync( + HttpContext context, CommonageDbContext db, ArchiveProfile profile, IStringLocalizer localizer, CancellationToken cancellationToken) + { + var identity = await profile.GetAsync(); + if (!identity.AllowSearchEngines) + return Results.NotFound(); + + var address = context.Request.BaseAddress(); + // What people agreed to before this existed was "this website". A feed is copied to other places, so those stay out of it. + var items = await db.PublishedItems + .Where(i => i.Contribution == null || i.Contribution.ConsentCoversSharing) + .Include(i => i.MediaFiles.Where(f => f.Status == ProcessingStatus.Ready).OrderBy(f => f.Position).ThenBy(f => f.Id).Take(1)) + .OrderByDescending(i => i.PublishedAt).ThenByDescending(i => i.Id) + .Take(FeedLength) + .AsNoTracking() + .ToListAsync(cancellationToken); + + var updated = items.Count == 0 ? DateTime.UtcNow : items.Max(i => i.UpdatedAt); + var feed = new XElement(Atom + "feed", + new XAttribute(XNamespace.Xml + "lang", CultureInfo.CurrentUICulture.Name), + new XElement(Atom + "id", $"{address}{FeedPath}"), + new XElement(Atom + "title", XmlText(localizer["What is new in {0}", identity.Name])), + new XElement(Atom + "updated", Timestamp(updated)), + new XElement(Atom + "link", new XAttribute("rel", "self"), new XAttribute("type", "application/atom+xml"), new XAttribute("href", $"{address}{FeedPath}")), + new XElement(Atom + "link", new XAttribute("rel", "alternate"), new XAttribute("type", "text/html"), new XAttribute("href", $"{address}/")), + new XElement(Atom + "author", new XElement(Atom + "name", XmlText(identity.Name))), + items.Select(item => Entry(item, address, identity, localizer))); + + context.Response.Headers.CacheControl = CacheControl; + return Results.Text(Written(feed), "application/atom+xml", Encoding.UTF8); + } + + private static XElement Entry(ArchiveItem item, string address, ArchiveIdentity identity, IStringLocalizer localizer) + { + var url = $"{address}/items/{item.Id}"; + var rights = RightsOptions.LabelOrAsk(localizer, item.RightsUri ?? identity.DefaultRightsUri); + var entry = new XElement(Atom + "entry", + new XElement(Atom + "id", url), + new XElement(Atom + "title", XmlText(item.Title)), + new XElement(Atom + "link", new XAttribute("rel", "alternate"), new XAttribute("type", "text/html"), new XAttribute("href", url)), + new XElement(Atom + "published", Timestamp(item.PublishedAt ?? item.UpdatedAt)), + new XElement(Atom + "updated", Timestamp(item.UpdatedAt)), + new XElement(Atom + "rights", XmlText(rights))); + + var content = new StringBuilder(); + if (item.MediaFiles.FirstOrDefault() is { } photo) + { + var alt = photo.AltText ?? item.Title; + content.Append(CultureInfo.InvariantCulture, $"""

{WebUtility.HtmlEncode(alt)}

"""); + } + if (!string.IsNullOrWhiteSpace(item.Description)) + content.Append(CultureInfo.InvariantCulture, $"

{WebUtility.HtmlEncode(item.Description)}

"); + if (item.Credit is { } credit) + content.Append(CultureInfo.InvariantCulture, $"

{WebUtility.HtmlEncode(credit)}

"); + if (content.Length > 0) + entry.Add(new XElement(Atom + "content", new XAttribute("type", "html"), XmlText(content.ToString()))); + return entry; + } + + private static async Task ServeSitemapAsync(HttpContext context, CommonageDbContext db, ArchiveProfile profile, CancellationToken cancellationToken) + { + if (!(await profile.GetAsync()).AllowSearchEngines) + return Results.NotFound(); + + var address = context.Request.BaseAddress(); + var collections = await db.Collections + .Where(c => c.Items.Any(i => i.Status == ItemStatus.Published)) + .OrderBy(c => c.Id) + .Select(c => c.Id) + .ToListAsync(cancellationToken); + var items = await db.PublishedItems + .OrderByDescending(i => i.PublishedAt).ThenByDescending(i => i.Id) + .Take(MaxSitemapItems) + .Select(i => new { i.Id, i.UpdatedAt }) + .ToListAsync(cancellationToken); + + static XElement Page(string url, DateTime? changed = null) => new(Sitemap + "url", + new XElement(Sitemap + "loc", url), + changed is { } at ? new XElement(Sitemap + "lastmod", at.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)) : null); + + var sitemap = new XElement(Sitemap + "urlset", + Page($"{address}/"), + Page($"{address}/collections"), + collections.Select(id => Page($"{address}/collections/{id}")), + items.Select(item => Page($"{address}/items/{item.Id}", item.UpdatedAt))); + + context.Response.Headers.CacheControl = CacheControl; + return Results.Text(Written(sitemap), "application/xml", Encoding.UTF8); + } + + private static string Written(XElement root) => new XDeclaration("1.0", "utf-8", null) + "\n" + root.ToString(SaveOptions.DisableFormatting); + + private static string Timestamp(DateTime utc) => DateTime.SpecifyKind(utc, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture); + + /// A pasted text can hold control characters. A web page shrugs those off, but one of them makes XML unreadable as a whole. + private static string XmlText(string text) => new(text.Where(XmlConvert.IsXmlChar).ToArray()); +} diff --git a/src/Commonage.Web/wwwroot/css/site.css b/src/Commonage.Web/wwwroot/css/site.css index 760f0f3..720b845 100644 --- a/src/Commonage.Web/wwwroot/css/site.css +++ b/src/Commonage.Web/wwwroot/css/site.css @@ -314,6 +314,13 @@ main:focus { background: var(--bs-secondary-bg); } +/* Whether something may be reused is part of what an item says, not small print. */ +.item-rights { + margin-block: 1rem; + padding-block-start: .75rem; + border-block-start: 1px solid var(--bs-border-color); +} + .item-neighbours { display: flex; flex-wrap: wrap; diff --git a/src/Commonage.Web/wwwroot/js/contribute.js b/src/Commonage.Web/wwwroot/js/contribute.js index 250011b..5cda089 100644 --- a/src/Commonage.Web/wwwroot/js/contribute.js +++ b/src/Commonage.Web/wwwroot/js/contribute.js @@ -269,13 +269,17 @@ function showRightsHolderWhenItApplies() { const holder = form.querySelector('[data-rights-holder]'); + const reuse = form.querySelector('[data-reuse]'); const radios = form.querySelectorAll('input[name="Input.Rights"]'); const field = holder.querySelector('input'); + const offer = reuse.querySelector('select'); const update = () => { - const applies = form.querySelector('input[name="Input.Rights"]:checked')?.value === 'PermissionFromRightsHolder'; - holder.hidden = !applies; + const answer = form.querySelector('input[name="Input.Rights"]:checked')?.value; + holder.hidden = answer !== 'PermissionFromRightsHolder'; + reuse.hidden = !answer || answer === 'Unknown'; // What cannot be seen is not sent. - field.disabled = !applies; + field.disabled = holder.hidden; + offer.disabled = reuse.hidden; }; radios.forEach(radio => radio.addEventListener('change', update)); update(); diff --git a/src/Commonage.Web/wwwroot/js/site.js b/src/Commonage.Web/wwwroot/js/site.js index 672ee76..4ab3bbe 100644 --- a/src/Commonage.Web/wwwroot/js/site.js +++ b/src/Commonage.Web/wwwroot/js/site.js @@ -123,6 +123,53 @@ for (const input of document.querySelectorAll('input[type=file][data-choose-text }); } +// Copying and sharing. navigator.clipboard only exists on HTTPS, and a browser can refuse it, so there is an older way too. +async function copyText(text) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + const area = document.createElement('textarea'); + area.value = text; + area.className = 'visually-hidden'; + document.body.append(area); + area.select(); + const copied = document.execCommand('copy'); + area.remove(); + return copied; + } +} + +function sayDone(button, done) { + const status = document.getElementById(button.getAttribute('aria-describedby')); + if (status) + status.textContent = done ? button.dataset.doneText : ''; +} + +for (const button of document.querySelectorAll('button[data-copy-from]')) { + button.hidden = false; + button.addEventListener('click', async () => { + sayDone(button, await copyText(document.getElementById(button.dataset.copyFrom).textContent)); + }); +} + +// A phone offers its own list of where to share to. Anywhere else, the link is copied. +for (const button of document.querySelectorAll('button[data-share]')) { + button.hidden = false; + button.addEventListener('click', async () => { + const shared = { title: button.dataset.shareTitle, url: button.dataset.share }; + if (navigator.share && navigator.canShare?.(shared)) { + try { + await navigator.share(shared); + } catch { + // They changed their mind, which is no mistake. + } + return; + } + sayDone(button, await copyText(button.dataset.share)); + }); +} + // A box to tick all boxes of a list. It only exists with JavaScript, because only JavaScript can make it work. for (const all of document.querySelectorAll('input[data-tick-all]')) { const boxes = () => [...document.getElementsByName(all.dataset.tickAll)]; diff --git a/tests/Commonage.BrowserTests/AccessibilityTests.cs b/tests/Commonage.BrowserTests/AccessibilityTests.cs index a55a131..92701e6 100644 --- a/tests/Commonage.BrowserTests/AccessibilityTests.cs +++ b/tests/Commonage.BrowserTests/AccessibilityTests.cs @@ -81,6 +81,10 @@ public async Task A_page_holds_up_with_much_longer_text(string path) /// For states a page only reaches by using it, as well as for each page as it loads. internal static async Task AssertNoViolationsAsync(IPage page, string what) { + // Away from any button and past its colour change on hover, or its contrast is measured halfway through. + await page.Mouse.MoveAsync(0, 0); + await page.WaitForTimeoutAsync(250); + var results = await page.RunAxe(new AxeRunOptions { RunOnly = new RunOnlyOptions { Type = "tag", Values = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"] }, diff --git a/tests/Commonage.BrowserTests/PhoneSendingTests.cs b/tests/Commonage.BrowserTests/PhoneSendingTests.cs index de8dba8..e14dd8e 100644 --- a/tests/Commonage.BrowserTests/PhoneSendingTests.cs +++ b/tests/Commonage.BrowserTests/PhoneSendingTests.cs @@ -70,6 +70,7 @@ public async Task The_photos_come_first_and_send_takes_the_whole_width() // It was about 1,900 px long before, and that was a reason to come back to it. var length = await page.EvaluateAsync("document.querySelector('main form').offsetHeight"); + // Fonts differ between systems, so one long line more on a Linux runner than on Windows already shows. Assert.True(length < 1500, $"The form is {length} px long on a phone."); // Once, on its own, with no introduction saying the same before it. diff --git a/tests/Commonage.BrowserTests/ReadableItemBrowserTests.cs b/tests/Commonage.BrowserTests/ReadableItemBrowserTests.cs index e6d6aba..431240f 100644 --- a/tests/Commonage.BrowserTests/ReadableItemBrowserTests.cs +++ b/tests/Commonage.BrowserTests/ReadableItemBrowserTests.cs @@ -178,9 +178,6 @@ public async Task A_curator_moves_photos_without_losing_what_was_typed_or_the_pl Assert.True(await page.Locator($"{Cards}:has-text('three.jpg') [data-photo-cover]").IsVisibleAsync()); Assert.False(await page.Locator($"{Cards}:has-text('two.jpg') [data-photo-cover]").IsVisibleAsync()); Assert.False(await page.Locator($"{Cards}:has-text('three.jpg') button[value=first]").IsVisibleAsync()); - // Away from the buttons and past their colour change on hover, which would otherwise be measured halfway. - await page.Mouse.MoveAsync(0, 0); - await page.WaitForTimeoutAsync(400); await AccessibilityTests.AssertNoViolationsAsync(page, "photos being put in order"); // Saved after all that, the order is what it looks like and nobody is told that someone else changed the item. diff --git a/tests/Commonage.BrowserTests/SharingBrowserTests.cs b/tests/Commonage.BrowserTests/SharingBrowserTests.cs new file mode 100644 index 0000000..08da552 --- /dev/null +++ b/tests/Commonage.BrowserTests/SharingBrowserTests.cs @@ -0,0 +1,129 @@ +using Microsoft.Playwright; + +namespace Commonage.BrowserTests; + +/// Citing and sharing an item, and offering reuse on the contribution form. +[Collection(nameof(BrowserCollection))] +public class SharingBrowserTests(BrowserFixture app) +{ + private const string Item = "/items/3"; + private const string Share = "button[data-share]"; + private const string Copy = "button[data-copy-from]"; + + [Fact] + public async Task A_citation_can_be_copied_in_one_go() + { + var page = await app.NewPageAsync(390, 844); + await page.Context.GrantPermissionsAsync(["clipboard-read", "clipboard-write"], new BrowserContextGrantPermissionsOptions { Origin = app.BaseUrl }); + await page.GotoAsync(Item); + + await page.ClickAsync("summary:text-is('Cite this item')"); + await page.ClickAsync(Copy); + await page.WaitForSelectorAsync("#copy-status:text-is('Copied')"); + + var copied = await page.EvaluateAsync("navigator.clipboard.readText()"); + Assert.Contains("Krause's bakery", copied); + Assert.Contains("1950s", copied); + Assert.Contains("Lindenau History Group", copied); + Assert.Contains($"{app.BaseUrl}{Item}", copied); + Assert.Equal((await page.InnerTextAsync("#citation")).Trim(), copied.Trim()); + await AccessibilityTests.AssertNoViolationsAsync(page, "a citation"); + } + + [Fact] + public async Task Where_a_device_cannot_share_the_link_is_copied_and_where_it_can_it_opens_its_own_list() + { + var page = await app.NewPageAsync(); + await page.Context.GrantPermissionsAsync(["clipboard-read", "clipboard-write"], new BrowserContextGrantPermissionsOptions { Origin = app.BaseUrl }); + await page.AddInitScriptAsync("delete Navigator.prototype.share; delete Navigator.prototype.canShare;"); + await page.GotoAsync(Item); + + await page.ClickAsync(Share); + await page.WaitForSelectorAsync("#share-status:text-is('Link copied')"); + Assert.Equal($"{app.BaseUrl}{Item}", await page.EvaluateAsync("navigator.clipboard.readText()")); + + var phone = await app.NewPageAsync(390, 844); + await phone.AddInitScriptAsync(""" + Navigator.prototype.canShare = () => true; + Navigator.prototype.share = shared => { window.sharedWith = shared; return Promise.resolve(); }; + """); + await phone.GotoAsync(Item); + + await phone.ClickAsync(Share); + + await phone.WaitForFunctionAsync("() => window.sharedWith"); + Assert.Equal($"{app.BaseUrl}{Item}", await phone.EvaluateAsync("window.sharedWith.url")); + Assert.Equal("Krause's bakery", await phone.EvaluateAsync("window.sharedWith.title")); + Assert.Equal("", await phone.InnerTextAsync("#share-status")); + } + + [Fact] + public async Task Someone_who_changes_their_mind_about_sharing_is_not_told_off() + { + var page = await app.NewPageAsync(390, 844); + var errors = new List(); + page.PageError += (_, error) => errors.Add(error); + await page.AddInitScriptAsync(""" + Navigator.prototype.canShare = () => true; + Navigator.prototype.share = () => { window.asked = true; return Promise.reject(new DOMException('No', 'AbortError')); }; + """); + await page.GotoAsync(Item); + + await page.ClickAsync(Share); + await page.WaitForFunctionAsync("() => window.asked"); + await page.WaitForTimeoutAsync(200); + + Assert.Empty(errors); + Assert.Equal("", await page.InnerTextAsync("#share-status")); + } + + [Fact] + public async Task Without_javascript_the_citation_is_there_to_read_and_nothing_pretends_to_work() + { + var page = await app.NewPageAsync(390, 844, javaScript: false); + await page.GotoAsync(Item); + + Assert.False(await page.Locator(Share).IsVisibleAsync()); + await page.ClickAsync("summary:text-is('Cite this item')"); + Assert.Contains("Krause's bakery", await page.InnerTextAsync("#citation")); + Assert.False(await page.Locator(Copy).IsVisibleAsync()); + Assert.Contains("May I reuse this?", await page.InnerTextAsync("main")); + } + + [Fact] + public async Task The_rights_are_on_a_phone_page_without_pushing_it_sideways() + { + var page = await app.NewPageAsync(320, 640); + await page.GotoAsync(Item); + + var overflow = await page.EvaluateAsync("document.documentElement.scrollWidth - document.documentElement.clientWidth"); + + Assert.True(overflow <= 0, $"Scrolls sideways by {overflow} px."); + Assert.Contains("Free to use with credit (CC BY)", await page.InnerTextAsync(".item-rights")); + Assert.Contains("Drawing: demo archive", await page.InnerTextAsync(".item-rights")); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Reuse_is_only_asked_about_when_it_can_be_theirs_to_offer(bool javaScript) + { + var page = await app.NewPageAsync(390, 844, javaScript); + await page.GotoAsync(app.ContributionPath); + var field = page.Locator("[name='Input.Reuse']"); + + // Without JavaScript nothing can show it later, so it is there from the start. + Assert.Equal(!javaScript, await field.IsVisibleAsync()); + + await page.CheckAsync("[name='Input.Rights'] >> nth=0"); + Assert.True(await field.IsVisibleAsync()); + + await page.CheckAsync("[name='Input.Rights'] >> nth=2"); + Assert.Equal(!javaScript, await field.IsVisibleAsync()); + + // The form still fits on a phone, and the question has to pass the same checks as the rest. + await page.CheckAsync("[name='Input.Rights'] >> nth=0"); + if (javaScript) + await AccessibilityTests.AssertNoViolationsAsync(page, "the question about reuse"); + } +} diff --git a/tests/Commonage.Tests/ExportTests.cs b/tests/Commonage.Tests/ExportTests.cs index 27c64ac..c496cfb 100644 --- a/tests/Commonage.Tests/ExportTests.cs +++ b/tests/Commonage.Tests/ExportTests.cs @@ -100,13 +100,13 @@ public async Task Without_personal_data_no_name_or_email_is_in_the_export() [Theory] [InlineData(typeof(ArchiveItem), "item", "id collectionId title description status createdAt updatedAt publishedAt contribution files tags rightsUri rightsHolder credit referenceNumber originalLocation receivedFrom receivedOn internalNotes needsTranscription", "Collection: by collectionId. MediaFiles: as files. DateText, DateFrom, DateTo: as date. TagNames: repeats tags. " - + "CanBePublished: follows from status. IsUntitled: follows from the empty title. DeletedAt: the bin is not exported. Version: only for open forms.")] + + "CanBePublished: follows from status. IsUntitled: follows from the empty title. MayTravel: follows from the contribution. DeletedAt: the bin is not exported. Version: only for open forms.")] [InlineData(typeof(MediaFile), "file", "id originalFileName contentType sizeBytes sha256 uploadedAt altText caption position rotation", "ArchiveItemId, ArchiveItem: the file is listed under its item. StorageId, OriginalKey, PreviewKey, ThumbnailKey: storage details. " + "Status, ProcessingError, PreviewWidth, PreviewHeight, CardKey, LargeKey, HasLarge, RenderedRotation, IsBeingTurned, DerivativeVersion, CurrentDerivativeVersion, MaxCaptionLength: previews are made anew. DeletedAt: the bin is not exported.")] [InlineData(typeof(ArchiveSettings), "settings", "defaultLanguage accentColor timeZone defaultRightsUri allowSearchEngines largePhotos privacyReviewed imprintNotNeeded restoreTestedAt logo texts", "Id, TheOnlyId: there is only one. LogoId: as logo.")] - [InlineData(typeof(Contribution), "contribution", "contributorName contributorEmail showName rights rightsHolder consentText referenceCode submittedAt reviewedAt personalDetailsRemovedAt contributionLink", + [InlineData(typeof(Contribution), "contribution", "contributorName contributorEmail showName rights rightsHolder consentText reuse consentCoversSharing referenceCode submittedAt reviewedAt personalDetailsRemovedAt contributionLink", "Id, ArchiveItemId, ArchiveItem: listed under its item. ContributionLinkId: the link is named. SendingFingerprint: only there to recognise a form sent twice.")] [InlineData(typeof(Collection), "collection", "id title description createdAt", "Items: each item names its collection.")] public async Task Every_field_is_exported_or_left_out_on_purpose(Type type, string part, string exported, string leftOut) diff --git a/tests/Commonage.Tests/SharingTests.cs b/tests/Commonage.Tests/SharingTests.cs new file mode 100644 index 0000000..9ad1cc6 --- /dev/null +++ b/tests/Commonage.Tests/SharingTests.cs @@ -0,0 +1,428 @@ +using System.Net; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Commonage.Web.Domain; +using Commonage.Web.Operations; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Commonage.Tests; + +/// Whether something may be reused, how to cite it, and what goes along when it is shared beyond this website. +[Collection(nameof(AppCollection))] +public class SharingTests(CommonageAppFactory app) +{ + private static readonly XNamespace Atom = "http://www.w3.org/2005/Atom"; + private static readonly XNamespace Sitemap = "http://www.sitemaps.org/schemas/sitemap/0.9"; + + private async Task PublishedAsync(string title, Action? change = null, Contribution? contribution = null) => + await app.WithDbAsync(async db => + { + var collection = new Collection { Title = $"Collection {Guid.NewGuid():N}", CreatedAt = DateTime.UtcNow }; + var item = new ArchiveItem { Title = title, Collection = collection, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, Contribution = contribution }; + change?.Invoke(item); + item.Publish(DateTime.UtcNow); + db.Add(item); + await db.SaveChangesAsync(); + return item; + }); + + private static Contribution From(bool consentCoversSharing) => new() + { + ContributorName = "Private Person", + ContributorEmail = "private-person@example.org", + ConsentText = "I agree.", + ConsentCoversSharing = consentCoversSharing, + SubmittedAt = DateTime.UtcNow, + }; + + private async Task AddPhotoAsync(int itemId) + { + using var curator = app.CreateClient(); + await Browser.LogInAsCuratorAsync(curator); + await Browser.UploadAsync(curator, $"/Admin/Items/Edit/{itemId}", "Upload", "photo.jpg", TestData.Photo); + for (var i = 0; i < 100 && await app.WithDbAsync(db => db.MediaFiles.AnyAsync(f => f.ArchiveItemId == itemId && f.Status != ProcessingStatus.Ready)); i++) + await Task.Delay(100); + } + + private async Task WithSettingsAsync(Action change, Func test) + { + var before = await app.WithDbAsync(db => db.ArchiveSettings.AsNoTracking().FirstOrDefaultAsync()); + await Change(settings => + { + change(settings); + }); + try + { + await test(); + } + finally + { + await Change(settings => + { + var original = before ?? new ArchiveSettings(); + settings.AllowSearchEngines = original.AllowSearchEngines; + settings.DefaultRightsUri = original.DefaultRightsUri; + }); + } + + async Task Change(Action apply) + { + await app.WithDbAsync(async db => + { + var settings = await db.ArchiveSettings.FirstOrDefaultAsync(); + if (settings is null) + db.ArchiveSettings.Add(settings = new ArchiveSettings()); + apply(settings); + return await db.SaveChangesAsync(); + }); + app.Services.GetRequiredService().Forget(); + } + } + + private static string? Meta(string page, string property) + { + var match = Regex.Match(page, $""" FeedAsync() + { + using var visitor = app.CreateClient(); + var response = await visitor.GetAsync("/feed.xml"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/atom+xml", response.Content.Headers.ContentType?.MediaType); + // Parsing is the test: one wrong character and a feed reader gives up on the whole feed. + return XDocument.Parse(await response.Content.ReadAsStringAsync()).Root!; + } + + private static XElement? EntryOf(XElement feed, int itemId) => + feed.Elements(Atom + "entry").SingleOrDefault(e => e.Element(Atom + "id")!.Value.EndsWith($"/items/{itemId}", StringComparison.Ordinal)); + + [Fact] + public async Task An_item_says_in_plain_language_whether_it_may_be_reused_and_who_to_credit() + { + var item = await PublishedAsync("Free to use", i => + { + i.RightsUri = RightsOptions.CcBy; + i.Credit = "Photo: Anna Krause"; + i.RightsHolder = "Somebody only curators know"; + }); + using var visitor = app.CreateClient(); + + var page = await visitor.GetStringAsync($"/items/{item.Id}"); + + Assert.Contains("May I reuse this?", page); + Assert.Contains("Free to use with credit (CC BY)", page); + Assert.Contains($"href=\"{RightsOptions.CcBy}\" rel=\"license noopener\"", page); + Assert.Contains("Photo: Anna Krause", page); + Assert.DoesNotContain("Somebody only curators know", page); + } + + [Fact] + public async Task Without_a_statement_of_its_own_an_item_follows_the_archive_or_says_to_ask() + { + var item = await PublishedAsync("Undecided"); + using var visitor = app.CreateClient(); + + await WithSettingsAsync(settings => settings.DefaultRightsUri = null, async () => + { + var page = await visitor.GetStringAsync($"/items/{item.Id}"); + Assert.Contains("Nothing has been decided yet: please ask the archive", page); + Assert.DoesNotContain("rel=\"license", page); + }); + + await WithSettingsAsync(settings => settings.DefaultRightsUri = RightsOptions.InCopyright, async () => + { + var page = await visitor.GetStringAsync($"/items/{item.Id}"); + Assert.Contains("In copyright: ask before reusing", page); + Assert.DoesNotContain("Nothing has been decided yet", page); + }); + } + + [Fact] + public async Task A_citation_says_who_what_when_and_where_to_find_it_and_never_who_sent_it() + { + var item = await PublishedAsync("Flood in Mill Street", i => + { + i.Credit = "Photo: Anna Krause"; + i.TrySetDate("March 1962"); + }, From(consentCoversSharing: true)); + using var visitor = app.CreateClient(); + + var page = await visitor.GetStringAsync($"/items/{item.Id}"); + + var citation = WebUtility.HtmlDecode(Regex.Match(page, """

]*>([^<]*)

""").Groups[1].Value); + Assert.StartsWith("Photo: Anna Krause. Flood in Mill Street. March 1962. ", citation); + Assert.Contains($"/items/{item.Id} (accessed ", citation); + Assert.DoesNotContain("Private Person", page); + Assert.Contains($"Production runs behind Caddy with the setting in compose.production.yaml. Every address a program copies has to be the public one.
+ [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($"""