package api
import (
"context"
"fmt"
"log"
"net/url"
"strconv"
"time"
"craftdesign.group/quarry/internal/attach"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// A chat message carries files the way a post does: pasted, dropped or
// picked in the box, sent to the repository's bucket at once, and named
// in the text as attach:NAME. A chat line is not in the repository's
// history, so its files are not either: no stub, no A card, only a row
// saying which message holds which bytes under which name. The row goes
// when the message is deleted or ages out of the channel, and the bytes
// with it where the repository's deleteObjects switch says removals
// delete.
// ChatFileStore is the forge-local record of chat files (oltp/chatfile.go).
type ChatFileStore interface {
AddChatFile(f oltp.ChatFile) error
ChatFile(repo string, seq uint64, name string) (oltp.ChatFile, bool, error)
DropChatFiles(repo string, seq uint64) ([]oltp.ChatFile, error)
PruneChatFiles(repo string, first uint64) ([]oltp.ChatFile, error)
ChatFileShared(sha string) bool
}
// chatFileTickets checks the tickets a chat post carries before the
// message is posted: unlike a post's attachments, a chat line cannot be
// written first and its files reported missing after, since the line
// would name pictures that never arrive. Each is the caller's, begun
// for chat in this repository, and its bytes are in the bucket.
func (d Deps) chatFileTickets(ctx context.Context, repo string, tickets []string) ([]oltp.UploadTicket, error) {
if len(tickets) == 0 {
return nil, nil
}
if err := checkAttachList(tickets); err != nil {
return nil, err
}
if d.Attachments == nil || d.ChatFiles == nil {
return nil, fmt.Errorf("this forge takes no chat files: %w", ops.ErrUnavailable)
}
_, _, bucket, err := d.boundBucket(repo)
if err != nil {
return nil, err
}
out := make([]oltp.UploadTicket, 0, len(tickets))
for _, tk := range tickets {
t, ok, err := d.Attachments.UploadTicket(tk)
if err != nil {
return nil, err
}
if !ok || t.Repo != repo || t.Family != "chat" {
return nil, fmt.Errorf("no such chat upload; attach the file again: %w", ops.ErrInvalid)
}
if t.Who != author(ctx) {
return nil, fmt.Errorf("%w: this upload was begun by someone else", ops.ErrForbidden)
}
size, present, err := bucket.Head(ctx, attachClient(), attach.Key(t.SHA256), time.Now())
if err != nil {
return nil, fmt.Errorf("the bucket could not be asked about %s (%v): %w", t.Name, err, ops.ErrUnavailable)
}
if !present || size != t.Size {
return nil, fmt.Errorf("%s did not finish uploading; attach it again: %w", t.Name, ops.ErrInvalid)
}
out = append(out, t)
}
return out, nil
}
// keepChatFiles records the checked tickets against the message seq and
// spends them. The message is already posted, so a row that fails is
// logged: its attach: link renders as text, which is the truth.
func (d Deps) keepChatFiles(repo string, seq uint64, ts []oltp.UploadTicket) {
for _, t := range ts {
if err := d.ChatFiles.AddChatFile(oltp.ChatFile{
Repo: repo, Seq: seq, Name: t.Name, Size: t.Size, Type: t.Type, SHA256: t.SHA256, Who: t.Who,
}); err != nil {
log.Printf("chat file: %s ^%d %s: %v", repo, seq, t.Name, err)
continue
}
if err := d.Attachments.DeleteUploadTicket(t.ID); err != nil {
log.Printf("chat file: %s: spending ticket: %v", repo, err)
}
}
}
// dropChatObjects deletes the bytes of chat files whose rows are gone,
// under the rule a removed attachment's follow: the repository's
// deleteObjects switch, and only when nothing else names the bytes.
func (d Deps) dropChatObjects(ctx context.Context, repo *Repo, name string, files []oltp.ChatFile) {
if len(files) == 0 {
return
}
_, b, bucket, err := d.boundBucket(name)
if err != nil || !b.DeleteObjects {
return
}
for _, f := range files {
if d.ChatFiles.ChatFileShared(f.SHA256) {
continue
}
if repo.Index != nil {
if n, err := repo.Index.LiveStubCount(f.SHA256); err != nil || n > 0 {
continue
}
}
if d.Local != nil {
if n, err := d.Local.LocalSHACount(name, f.SHA256); err != nil || n > 0 {
continue
}
}
if err := bucket.Delete(ctx, attachClient(), attach.Key(f.SHA256), time.Now()); err != nil {
log.Printf("chat file: %s: deleting %s: %v", name, attach.Key(f.SHA256), err)
}
}
}
// chatFileURL is where attach:NAME in message seq points: the page's
// route, which asks chat.file for a fresh link each time and answers
// 404 for a file the line does not carry. It is not looked up here: the
// live stream renders a line the moment it is posted, before its files
// are recorded, and a lookup then would draw the picture as text.
func (d Deps) chatFileURL(repo string, seq uint64, name string, image bool) string {
if d.ChatFiles == nil || name == "" {
return ""
}
u := "/r/" + repo + "/chat/" + strconv.FormatUint(seq, 10) + "/file/" + url.PathEscape(name)
if !image {
u += "?dl=1"
}
return u
}
type chatFileIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Seq uint64 `json:"seq" validate:"required"`
Name string `json:"name" validate:"required,max=255"`
}
type chatFileOut struct {
Name string `json:"name"`
Type string `json:"type"`
Size int64 `json:"size"`
Download string `json:"download"`
Inline string `json:"inline,omitempty"`
Expires string `json:"expires"`
}
func registerChatFile(r *ops.Registry, d Deps) error {
return ops.Register(r, ops.Op[chatFileIn, chatFileOut]{
Name: "chat.file",
Caps: "C",
Doc: "Where a file a chat message carries is: a presigned GET good for an hour that names the file and its type, " +
"and an inline one when the type may render in a page (SVG never). Gone when the message is deleted or ages out.",
Kind: ops.Query,
Auth: ops.Token,
Route: "GET /api/v1/r/{repo}/chat/{seq}/file/{name}",
Run: func(ctx context.Context, in chatFileIn) (chatFileOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return chatFileOut{}, err
}
if d.ChatFiles == nil {
return chatFileOut{}, fmt.Errorf("no chat file %q: %w", in.Name, ops.ErrNotFound)
}
f, ok, err := d.ChatFiles.ChatFile(in.Repo, in.Seq, in.Name)
if err != nil {
return chatFileOut{}, err
}
if !ok {
return chatFileOut{}, fmt.Errorf("message %d carries no file %q: %w", in.Seq, in.Name, ops.ErrNotFound)
}
// A deleted message's rows are dropped with it; this is the
// check for the moment between the tombstone and the drop.
if gone, err := d.chatDeleted(repo.Code, in.Seq); err != nil || gone {
return chatFileOut{}, fmt.Errorf("message %d was deleted: %w", in.Seq, ops.ErrNotFound)
}
_, _, bucket, err := d.boundBucket(in.Repo)
if err != nil {
return chatFileOut{}, fmt.Errorf("%s has no bucket bound, so this file cannot be reached: %w", in.Repo, ops.ErrNotFound)
}
now := time.Now()
out := chatFileOut{Name: f.Name, Type: f.Type, Size: f.Size, Expires: now.Add(linkExpiry).UTC().Format(time.RFC3339)}
dl, err := bucket.PresignGet(attach.Key(f.SHA256), linkExpiry, now, f.Name, f.Type, false)
if err != nil {
return chatFileOut{}, err
}
out.Download = dl.String()
if attach.Inline(f.Type) {
il, err := bucket.PresignGet(attach.Key(f.SHA256), linkExpiry, now, f.Name, f.Type, true)
if err != nil {
return chatFileOut{}, err
}
out.Inline = il.String()
}
return out, nil
},
})
}
// ChatUploadSweep deletes the bytes of chat files that were sent to the
// bucket and never sent in a message: pasted and taken out again, or
// left in a box nobody sent. Their tickets expire unspent, and each is
// handled as a deleted message's files are, under the repository's
// deleteObjects switch and only when nothing else names the bytes.
func (d Deps) ChatUploadSweep(ctx context.Context) {
if d.Attachments == nil || d.ChatFiles == nil {
return
}
expired, err := d.Attachments.ExpireChatUploads()
if err != nil {
log.Printf("chat file: sweeping unsent uploads: %v", err)
return
}
byRepo := map[string][]oltp.ChatFile{}
for _, t := range expired {
byRepo[t.Repo] = append(byRepo[t.Repo], oltp.ChatFile{Repo: t.Repo, Name: t.Name, SHA256: t.SHA256})
}
for name, files := range byRepo {
repo, ok := d.Repos.Get(name)
if !ok {
continue
}
d.dropChatObjects(ctx, repo, name, files)
}
}