305 lines
9.0 KiB
Go
305 lines
9.0 KiB
Go
package platform
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"image"
|
|
_ "image/gif"
|
|
_ "image/jpeg"
|
|
_ "image/png"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
_ "golang.org/x/image/webp"
|
|
)
|
|
|
|
const assetLimit = 20 << 20
|
|
const assetPixelLimit = 40_000_000
|
|
|
|
type assetInfo struct {
|
|
ID string `json:"id"`
|
|
MIME string `json:"mime_type"`
|
|
Bytes int `json:"bytes"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
func (s *service) assetURL(project, workspace, id string) string {
|
|
return s.origin + "/projects/" + url.PathEscape(project) + "/workspaces/" + url.PathEscape(workspace) + "/assets/" + id
|
|
}
|
|
|
|
// Decode headers first and bound allocation before decoding the actual image.
|
|
// The MIME comes from the decoder, never from the filename or upload header.
|
|
func inspectAsset(encoded []byte) (string, int, int, error) {
|
|
config, format, err := image.DecodeConfig(bytes.NewReader(encoded))
|
|
if err != nil {
|
|
return "", 0, 0, err
|
|
}
|
|
if !canvasChoice(format, "png", "jpeg", "gif", "webp") || config.Width <= 0 || config.Height <= 0 || config.Width > assetPixelLimit || config.Height > assetPixelLimit || int64(config.Width)*int64(config.Height) > assetPixelLimit {
|
|
return "", 0, 0, errors.New("invalid_image_dimensions")
|
|
}
|
|
decoded, actual, err := image.Decode(bytes.NewReader(encoded))
|
|
if err != nil {
|
|
return "", 0, 0, err
|
|
}
|
|
if actual != format || decoded.Bounds().Dx() != config.Width || decoded.Bounds().Dy() != config.Height {
|
|
return "", 0, 0, errors.New("invalid_image")
|
|
}
|
|
return "image/" + format, config.Width, config.Height, nil
|
|
}
|
|
func (s *service) uploadAsset(w http.ResponseWriter, r *http.Request, project, account string) error {
|
|
if r.Method != http.MethodPost {
|
|
reject(w, 405, "method_not_allowed")
|
|
return nil
|
|
}
|
|
key := canvasIdempotencyKey(r)
|
|
if !uuidSlugPattern.MatchString(key) {
|
|
reject(w, 400, "invalid_idempotency_key")
|
|
return nil
|
|
}
|
|
tx, err := s.db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
workspace := r.PathValue("workspace")
|
|
_, role, err := lockWorkspace(r.Context(), tx, project, workspace, account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if role == "viewer" {
|
|
reject(w, 403, "forbidden")
|
|
return nil
|
|
}
|
|
r.Body = http.MaxBytesReader(w, r.Body, assetLimit+(64<<10))
|
|
reader, err := r.MultipartReader()
|
|
if err != nil {
|
|
reject(w, 400, "invalid_asset")
|
|
return nil
|
|
}
|
|
var encoded []byte
|
|
count := 0
|
|
for {
|
|
part, e := reader.NextPart()
|
|
if e == io.EOF {
|
|
break
|
|
}
|
|
if e != nil {
|
|
reject(w, 413, "asset_too_large")
|
|
return nil
|
|
}
|
|
if part.FormName() != "image" && part.FormName() != "file" {
|
|
part.Close()
|
|
reject(w, 400, "invalid_asset")
|
|
return nil
|
|
}
|
|
count++
|
|
if count > 1 {
|
|
part.Close()
|
|
reject(w, 400, "invalid_asset")
|
|
return nil
|
|
}
|
|
encoded, e = io.ReadAll(io.LimitReader(part, assetLimit+1))
|
|
part.Close()
|
|
if e != nil || len(encoded) > assetLimit {
|
|
reject(w, 413, "asset_too_large")
|
|
return nil
|
|
}
|
|
}
|
|
if count != 1 || len(encoded) == 0 {
|
|
reject(w, 400, "invalid_asset")
|
|
return nil
|
|
}
|
|
mime, width, height, err := inspectAsset(encoded)
|
|
if err != nil {
|
|
reject(w, 400, "invalid_asset")
|
|
return nil
|
|
}
|
|
digest := sha256.Sum256(encoded)
|
|
hash := hex.EncodeToString(digest[:])
|
|
info := assetInfo{MIME: mime, Bytes: len(encoded), Width: width, Height: height}
|
|
var previous string
|
|
err = tx.QueryRowContext(r.Context(), `SELECT id::text,digest FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND uploaded_by=$3 AND idempotency_key=$4`, project, workspace, account, key).Scan(&info.ID, &previous)
|
|
if err == nil {
|
|
if previous != hash {
|
|
reject(w, 409, "idempotency_conflict")
|
|
return nil
|
|
}
|
|
info.URL = s.assetURL(project, workspace, info.ID)
|
|
reply(w, 201, info)
|
|
return nil
|
|
}
|
|
if err != sql.ErrNoRows {
|
|
return err
|
|
}
|
|
err = tx.QueryRowContext(r.Context(), `INSERT INTO canvas_assets(project,workspace_id,uploaded_by,mime_type,bytes,width,height,digest,idempotency_key) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id::text`, project, workspace, account, mime, encoded, width, height, hash, key).Scan(&info.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
info.URL = s.assetURL(project, workspace, info.ID)
|
|
reply(w, 201, info)
|
|
return nil
|
|
}
|
|
func assetReadable(r *http.Request, tx *sql.Tx, project, workspace, id, account string) (bool, error) {
|
|
var uploader string
|
|
err := tx.QueryRowContext(r.Context(), `SELECT uploaded_by::text FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, id).Scan(&uploader)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
var bindings int
|
|
err = tx.QueryRowContext(r.Context(), `SELECT count(*) FROM canvas_asset_bindings WHERE project=$1 AND workspace_id=$2 AND asset_id=$3`, project, workspace, id).Scan(&bindings)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if bindings == 0 {
|
|
if uploader == account {
|
|
return true, nil
|
|
}
|
|
var admin bool
|
|
err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM workspace_members WHERE project=$1 AND workspace_id=$2 AND account_id=$3 AND role='admin')`, project, workspace, account).Scan(&admin)
|
|
return admin, err
|
|
}
|
|
rows, err := tx.QueryContext(r.Context(), `SELECT board_id::text FROM canvas_asset_bindings WHERE project=$1 AND workspace_id=$2 AND asset_id=$3 ORDER BY board_id`, project, workspace, id)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
boards := []string{}
|
|
for rows.Next() {
|
|
var board string
|
|
if err = rows.Scan(&board); err != nil {
|
|
rows.Close()
|
|
return false, err
|
|
}
|
|
boards = append(boards, board)
|
|
}
|
|
err = rows.Err()
|
|
rows.Close()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, board := range boards {
|
|
_, _, err := lockMaterial(r.Context(), tx, project, workspace, board, account)
|
|
if err == nil {
|
|
return true, nil
|
|
}
|
|
if err != sql.ErrNoRows {
|
|
return false, err
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
func (s *service) getAsset(w http.ResponseWriter, r *http.Request, project, account string) error {
|
|
if r.Method != http.MethodGet {
|
|
reject(w, 405, "method_not_allowed")
|
|
return nil
|
|
}
|
|
id := r.PathValue("asset")
|
|
if !uuidSlugPattern.MatchString(id) {
|
|
reject(w, 404, "not_found")
|
|
return nil
|
|
}
|
|
workspace := r.PathValue("workspace")
|
|
tx, err := s.db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
// Serialize with permission and binding changes before making the read decision.
|
|
var locked string
|
|
err = tx.QueryRowContext(r.Context(), `SELECT id::text FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE`, project, workspace).Scan(&locked)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
allowed, err := assetReadable(r, tx, project, workspace, id, account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !allowed {
|
|
reject(w, 404, "not_found")
|
|
return nil
|
|
}
|
|
var encoded []byte
|
|
var mime string
|
|
err = tx.QueryRowContext(r.Context(), `SELECT mime_type,bytes FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, id).Scan(&mime, &encoded)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
w.Header().Set("Content-Type", mime)
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(encoded)))
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Cache-Control", "private, no-store")
|
|
w.WriteHeader(200)
|
|
_, err = w.Write(encoded)
|
|
return err
|
|
}
|
|
|
|
func (s *service) attachCanvasAssets(r *http.Request, tx *sql.Tx, project, workspace, board string, raw json.RawMessage) error {
|
|
for _, data := range canvasNodeData(raw) {
|
|
id, _ := data["assetId"].(string)
|
|
if id == "" {
|
|
src, _ := data["src"].(string)
|
|
prefix := s.assetURL(project, workspace, "")
|
|
if strings.HasPrefix(src, prefix) {
|
|
candidate := strings.TrimPrefix(src, prefix)
|
|
if uuidSlugPattern.MatchString(candidate) {
|
|
id = candidate
|
|
}
|
|
}
|
|
}
|
|
if id == "" {
|
|
continue
|
|
}
|
|
_, err := tx.ExecContext(r.Context(), `INSERT INTO canvas_asset_bindings(project,workspace_id,asset_id,board_id) VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING`, project, workspace, id, board)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func (s *service) validateCanvasAssets(r *http.Request, tx *sql.Tx, project, workspace, account string, raw json.RawMessage) (bool, error) {
|
|
for _, data := range canvasNodeData(raw) {
|
|
id, explicit := data["assetId"].(string)
|
|
if !explicit {
|
|
src, _ := data["src"].(string)
|
|
prefix := s.assetURL(project, workspace, "")
|
|
if !strings.HasPrefix(src, prefix) {
|
|
continue
|
|
}
|
|
id = strings.TrimPrefix(src, prefix)
|
|
if !uuidSlugPattern.MatchString(id) {
|
|
return false, nil
|
|
}
|
|
}
|
|
if data["src"] != s.assetURL(project, workspace, id) {
|
|
return false, nil
|
|
}
|
|
allowed, err := assetReadable(r, tx, project, workspace, id, account)
|
|
if err == sql.ErrNoRows {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !allowed {
|
|
return false, nil
|
|
}
|
|
// assetId is optional. Stable managed URLs acquire the same durable binding.
|
|
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_asset_bindings(project,workspace_id,asset_id,board_id) VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING`, project, workspace, id, r.PathValue("material"))
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|