Skip to content
quarry Artifact [4f90ffac69]
Login

Artifact [4f90ffac69]

Artifact 4f90ffac693517b4c4aed592d453b65938af2f5e35db74db64717bb70bef46bf:


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
		},
	})
}