Summary#
A file a person attaches to a message should be seen by exactly the people who may see the message. [Attachment]
says that, once, on the reference:
entity MessageFile {
[Required] Message Message;
[Required, Attachment] FileAsset File; // readable wherever this row is
}Whoever may read a MessageFile row may read its file — and every version of it. Nobody else gains anything: a
customer reads the screenshot on their own conversation's reply, and not the one on another customer's, and not the
one on an internal note their own conversation carries, because they cannot read those rows.
Signature#
[Attachment] FileAsset File; // one file per row; [Required] or nullable, as the row needsOnly on an entity, and only on a single FileAsset reference. For several files, give each its own row — an
entity holding one [Attachment] FileAsset and a reference to what it belongs to.
Description#
Why is a plain reference not enough?#
A file's access is normally its OWN: its owner, a FileGrant, or IsPublic. A business record that points at a
file says nothing about who may see the bytes — an invoice may be linked from an order and still be private to
finance. That is the right default, and it stays the default.
An attachment is the other case: the file has no audience of its own, it simply belongs to the row. [Attachment]
is how you say so, and the platform then answers "may this person read the file?" by asking the row's own rules.
Who may read an attached file?#
The file is readable when any of these holds:
- its own rules admit the reader (they own it, it was shared with them, or it is public), or
- it is held by an
[Attachment]on a row the reader's read rules on that row's entity admit.
The second half is the row's whole composed read filter — every allow, every deny, every when — so a deny
that hides a note from a customer hides the note's file too. The file's history follows it: each version is readable
exactly when the file is.
This holds on every path that reads a file: a query, Image(fileAsset:), File.ReadAllBytes, the signed address
the renderer asks for, and a download link. A reader who may not read it gets a 404, the same answer as for a file
that does not exist.
Who may attach a file?#
Setting an [Attachment] reference shares the file with everyone who may read the row, so it takes what sharing
takes: the file's owner, or someone holding a FileGrant at Share or above. Being able to read a file —
through another attachment, or a Read grant — is not enough.
Without that rule, anyone could create a row they can read, point its attachment at a file id they learned, and read the file through their own row. So the commit refuses it, with a sentence saying why:
This file cannot be attached here: attaching it would show it to everyone who can read this MessageFile, and only its owner (or someone it was shared with to share further) may do that. Upload your own copy instead.
Uploading a file and attaching it in the same unit of work works, because the person who uploads a file owns it. Workflows and other system work are not judged, so a mail receiver can store an arriving file and attach it.
Clearing an attachment shares nothing and is never refused here. A set-based statement (UpdateByFilterAsync, a bulk
insert) may not name an [Attachment] column at all under a caller's security — each file is judged one at a time.
A signed-out visitor's own files#
A customer in a support widget is usually not signed in, and the screenshot they paste still has to be theirs. A file
made with nobody signed in is its visit's — the visit the server issued that browser (Visitor.Id). The platform
stamps it on FileAsset.CreatedByVisit when the file is made, overwriting anything the code set, and from then on
that browser reads it, attaches it and replaces it exactly as a signed-in owner would; another browser, a signed-in
person and a request with no visit do not. A file a signed-in person or the system makes has no visit.
The upload half is upload: a signed-out page may upload privately (a path the platform chooses), and only on a visit its cookie proves.
Downloading a file#
Link(fileAsset: file.Id) { Text("Download"); } is a link that SAVES the file under its own name: the page asks for
an address signed for this reader (the same one Image(fileAsset:) uses) and adds download=1, so the server answers
Content-Disposition: attachment. A file whose type a browser would open as a document — HTML, SVG, XML,
JavaScript — is sent as an attachment whether or not anybody asked, always with nosniff, so an .html a customer
mailed in is saved and never rendered on your origin.
Showing a file's name, type and size on a page#
An included FileAsset is a row like any other: .Include(r => r.File) brings its Name and MimeType, and a page
reads them directly. A file's size lives on its content, one hop further, so include that hop when you show it —
.Include(r => r.File.CurrentVersion) brings the file and its current content together.
The same holds after an action attaches a file: the page's live var asks again, and the new file arrives with the
row that points at it, so r.File.Name reads the new name on the next frame.
Examples#
A document list that names each file#
app Filing { use Osysharp.Storage; use Osysharp.Ui; }
using Osysharp.Storage;
using Osysharp.Ui;
[Principal] entity User { [Required, MaxLength(200)] string Email; }
entity Filing {
[MaxLength(200)] string Title = "";
[Attachment] FileAsset? Scan;
security { allow read, create, update when IsAuthenticated; }
}
[Authorize(IsAuthenticated)]
public List<Filing> Filings() {
return Filing.Where(f => f.Scan != null).Include(f => f.Scan.CurrentVersion).OrderBy(f => f.Title).Take(50).ToList();
}
[Route("/filings")]
component FilingsPage() {
live var rows = Filings();
render {
Stack(gap: 2) {
foreach (var f in rows) {
if (f.Scan != null) {
Text(f.Title + " — " + f.Scan.Name + " · " + f.Scan.MimeType + " · " + Text.ByteSize(f.Scan.CurrentVersion.Size ?? 0));
}
}
}
}
}A conversation where a note's file stays internal#
using Osysharp.Storage;
[Principal] entity User { [Required, MaxLength(200)] string Email; }
entity Agent { [Required] User Member; security { allow read when IsAuthenticated; } }
policy IsAgent => Agent.Any(a => a.Member == user);
entity Ticket {
[Required] User Customer;
security { allow read where Customer == user; allow read, create when IsAgent; }
}
entity Reply {
[Required] Ticket Ticket;
bool Internal;
security {
allow read where Ticket.Customer == user && !Internal;
allow read, create when IsAgent;
}
}
entity ReplyFile {
[Required] Reply Reply;
[Required, Attachment] FileAsset File;
security {
allow read where Reply.Ticket.Customer == user && !Reply.Internal;
allow read, create when IsAgent;
}
}
/// An agent answers with a screenshot: the file is theirs, and attaching it shares it with whoever reads the reply.
void AnswerWithAScreenshot(Ticket ticket, byte[] png) {
var reply = new Reply { Ticket = ticket, Internal = false };
var shot = File.Create("annotated.png", "image/png", png);
new ReplyFile { Reply = reply, File = shot };
}See also#
- Files (addressing something the app stores) — the two ways the platform stores a file, and which one you hold
- A file's history — supersede, and what each version keeps — a file's history, which an attachment's readers see too
- Showing a picture on a page — showing an attached picture on a page
- upload — the picker, a pasted or dropped file, and a visitor's private upload
- The Conversations kit — one timeline per conversation, across every channel — attachments on a conversation's messages, built on this