Add self-service photo and pronunciation upload with archiving and change log
This commit is contained in:
+65
-3
@@ -21,9 +21,9 @@ import (
|
||||
"golang.org/x/image/draw"
|
||||
_ "golang.org/x/image/webp"
|
||||
|
||||
"heliosian/internal/data"
|
||||
"google.golang.org/api/drive/v3"
|
||||
"google.golang.org/api/option"
|
||||
"heliosian/internal/data"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -54,7 +54,7 @@ type Store struct {
|
||||
func New() (*Store, error) {
|
||||
service, err := drive.NewService(context.Background(),
|
||||
option.WithCredentialsFile(data.KeyFile),
|
||||
option.WithScopes(drive.DriveReadonlyScope))
|
||||
option.WithScopes(drive.DriveScope))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -107,6 +107,9 @@ func (s *Store) refresh() error {
|
||||
return fmt.Errorf("list %s: %w", folderName, err)
|
||||
}
|
||||
for _, f := range list.Files {
|
||||
if f.MimeType == folderMime {
|
||||
continue
|
||||
}
|
||||
base := strings.TrimSuffix(f.Name, path.Ext(f.Name))
|
||||
if strings.HasSuffix(base, "-thumb") {
|
||||
continue
|
||||
@@ -196,19 +199,78 @@ func (s *Store) refresh() error {
|
||||
}
|
||||
|
||||
func (s *Store) subfolder(name string) (string, error) {
|
||||
return s.subfolderIn(s.root, name, false)
|
||||
}
|
||||
|
||||
func (s *Store) subfolderIn(parent, name string, create bool) (string, error) {
|
||||
list, err := s.service.Files.List().
|
||||
Q(fmt.Sprintf("name = '%s' and '%s' in parents and mimeType = '%s' and trashed = false", name, s.root, folderMime)).
|
||||
Q(fmt.Sprintf("name = '%s' and '%s' in parents and mimeType = '%s' and trashed = false", name, parent, folderMime)).
|
||||
SupportsAllDrives(true).IncludeItemsFromAllDrives(true).Corpora("allDrives").
|
||||
Fields("files(id)").Do()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("find folder %s: %w", name, err)
|
||||
}
|
||||
if len(list.Files) == 0 && create {
|
||||
folder, err := s.service.Files.Create(&drive.File{
|
||||
Name: name,
|
||||
MimeType: folderMime,
|
||||
Parents: []string{parent},
|
||||
}).SupportsAllDrives(true).Fields("id").Do()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create folder %s: %w", name, err)
|
||||
}
|
||||
return folder.Id, nil
|
||||
}
|
||||
if len(list.Files) != 1 {
|
||||
return "", fmt.Errorf("expected one %s folder, found %d", name, len(list.Files))
|
||||
}
|
||||
return list.Files[0].Id, nil
|
||||
}
|
||||
|
||||
func (s *Store) Refresh() error {
|
||||
return s.refresh()
|
||||
}
|
||||
|
||||
func (s *Store) Upload(folder, base, ext, mimeType string, content []byte) (string, error) {
|
||||
folderID, err := s.subfolder(folder)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
archived := ""
|
||||
s.mu.RLock()
|
||||
existing, exists := s.entries[folder+"/"+base]
|
||||
s.mu.RUnlock()
|
||||
if exists {
|
||||
current, err := s.service.Files.Get(existing.id).SupportsAllDrives(true).Fields("name").Do()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("look up current %s/%s: %w", folder, base, err)
|
||||
}
|
||||
archiveID, err := s.subfolderIn(folderID, "archive", true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
archived = strings.TrimSuffix(current.Name, path.Ext(current.Name)) +
|
||||
"-" + time.Now().UTC().Format("20060102-150405") + path.Ext(current.Name)
|
||||
_, err = s.service.Files.Update(existing.id, &drive.File{Name: archived}).
|
||||
AddParents(archiveID).RemoveParents(folderID).SupportsAllDrives(true).Do()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("archive %s/%s: %w", folder, base, err)
|
||||
}
|
||||
}
|
||||
_, err = s.service.Files.Create(&drive.File{
|
||||
Name: base + "." + ext,
|
||||
MimeType: mimeType,
|
||||
Parents: []string{folderID},
|
||||
}).SupportsAllDrives(true).Media(bytes.NewReader(content)).Do()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("upload %s/%s: %w", folder, base, err)
|
||||
}
|
||||
if err := s.refresh(); err != nil {
|
||||
return "", fmt.Errorf("refresh after upload: %w", err)
|
||||
}
|
||||
return archived, nil
|
||||
}
|
||||
|
||||
func (s *Store) download(id string) ([]byte, error) {
|
||||
resp, err := s.service.Files.Get(id).SupportsAllDrives(true).Download()
|
||||
if err != nil {
|
||||
|
||||
+99
-1
@@ -19,7 +19,7 @@ type Sheet struct {
|
||||
func NewSheet(spreadsheets map[string]string) (*Sheet, error) {
|
||||
service, err := sheets.NewService(context.Background(),
|
||||
option.WithCredentialsFile(KeyFile),
|
||||
option.WithScopes(sheets.SpreadsheetsReadonlyScope))
|
||||
option.WithScopes(sheets.SpreadsheetsScope))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -38,6 +38,104 @@ func (s *Sheet) Table(app, name string) ([]map[string]string, error) {
|
||||
return toRecords(resp.Values), nil
|
||||
}
|
||||
|
||||
func (s *Sheet) SetColumn(app, table, keyColumn, keyValue, column, value string) error {
|
||||
id, ok := s.spreadsheets[app]
|
||||
if !ok {
|
||||
return fmt.Errorf("no spreadsheet configured for app %q", app)
|
||||
}
|
||||
quoted := "'" + strings.ReplaceAll(table, "'", "''") + "'"
|
||||
resp, err := s.service.Spreadsheets.Values.Get(id, quoted).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(resp.Values) == 0 {
|
||||
return fmt.Errorf("table %s is empty", table)
|
||||
}
|
||||
keyIdx, colIdx := -1, -1
|
||||
for i, cell := range resp.Values[0] {
|
||||
switch strings.TrimSpace(fmt.Sprint(cell)) {
|
||||
case keyColumn:
|
||||
keyIdx = i
|
||||
case column:
|
||||
colIdx = i
|
||||
}
|
||||
}
|
||||
if keyIdx < 0 || colIdx < 0 {
|
||||
return fmt.Errorf("table %s is missing column %q or %q", table, keyColumn, column)
|
||||
}
|
||||
ranges := []*sheets.ValueRange{}
|
||||
for i, row := range resp.Values[1:] {
|
||||
if keyIdx >= len(row) || !strings.EqualFold(strings.TrimSpace(fmt.Sprint(row[keyIdx])), keyValue) {
|
||||
continue
|
||||
}
|
||||
ranges = append(ranges, &sheets.ValueRange{
|
||||
Range: fmt.Sprintf("%s!%s%d", quoted, columnName(colIdx), i+2),
|
||||
Values: [][]interface{}{{value}},
|
||||
})
|
||||
}
|
||||
if len(ranges) == 0 {
|
||||
return fmt.Errorf("no row in %s has %s = %q", table, keyColumn, keyValue)
|
||||
}
|
||||
_, err = s.service.Spreadsheets.Values.BatchUpdate(id, &sheets.BatchUpdateValuesRequest{
|
||||
ValueInputOption: "RAW",
|
||||
Data: ranges,
|
||||
}).Do()
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Sheet) Append(app, table string, header, row []string) error {
|
||||
id, ok := s.spreadsheets[app]
|
||||
if !ok {
|
||||
return fmt.Errorf("no spreadsheet configured for app %q", app)
|
||||
}
|
||||
meta, err := s.service.Spreadsheets.Get(id).Fields("sheets(properties(title))").Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exists := false
|
||||
for _, sh := range meta.Sheets {
|
||||
if sh.Properties.Title == table {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
quoted := "'" + strings.ReplaceAll(table, "'", "''") + "'"
|
||||
if !exists {
|
||||
_, err := s.service.Spreadsheets.BatchUpdate(id, &sheets.BatchUpdateSpreadsheetRequest{
|
||||
Requests: []*sheets.Request{{AddSheet: &sheets.AddSheetRequest{
|
||||
Properties: &sheets.SheetProperties{Title: table},
|
||||
}}},
|
||||
}).Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create table %s: %w", table, err)
|
||||
}
|
||||
if err := s.appendRow(id, quoted, header); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.appendRow(id, quoted, row)
|
||||
}
|
||||
|
||||
func (s *Sheet) appendRow(id, quotedTable string, row []string) error {
|
||||
values := make([]interface{}, len(row))
|
||||
for i, cell := range row {
|
||||
values[i] = cell
|
||||
}
|
||||
_, err := s.service.Spreadsheets.Values.Append(id, quotedTable, &sheets.ValueRange{
|
||||
Values: [][]interface{}{values},
|
||||
}).ValueInputOption("RAW").InsertDataOption("INSERT_ROWS").Do()
|
||||
return err
|
||||
}
|
||||
|
||||
func columnName(idx int) string {
|
||||
name := ""
|
||||
for idx >= 0 {
|
||||
name = string(rune('A'+idx%26)) + name
|
||||
idx = idx/26 - 1
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func toRecords(values [][]interface{}) []map[string]string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -27,6 +27,10 @@ func NewCache(source data.Source, geocoder *geocode.Client) (*Cache, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Cache) Refresh() error {
|
||||
return c.refresh()
|
||||
}
|
||||
|
||||
func (c *Cache) Model() *Model {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package directory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"heliosian/internal/auth"
|
||||
"heliosian/internal/blob"
|
||||
"heliosian/internal/data"
|
||||
)
|
||||
|
||||
const changeLogTable = "Change Log"
|
||||
|
||||
var changeLogHeader = []string{"Timestamp", "Actor", "Target", "Kind", "File", "Archived"}
|
||||
|
||||
var photoExtensions = map[string]string{
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
}
|
||||
|
||||
var audioExtensions = map[string]string{
|
||||
"audio/webm": "webm",
|
||||
"video/webm": "webm",
|
||||
"audio/mp4": "m4a",
|
||||
"video/mp4": "m4a",
|
||||
"audio/x-m4a": "m4a",
|
||||
"audio/mpeg": "mp3",
|
||||
"audio/ogg": "ogg",
|
||||
"audio/wav": "wav",
|
||||
}
|
||||
|
||||
type uploader struct {
|
||||
cache *Cache
|
||||
sheet *data.Sheet
|
||||
store *blob.Store
|
||||
}
|
||||
|
||||
func RegisterUpload(mux *http.ServeMux, cache *Cache, sheet *data.Sheet, store *blob.Store) {
|
||||
u := uploader{cache: cache, sheet: sheet, store: store}
|
||||
mux.HandleFunc("POST /api/directory/upload", u.upload)
|
||||
}
|
||||
|
||||
func (u uploader) upload(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 30<<20)
|
||||
if err := r.ParseMultipartForm(30 << 20); err != nil {
|
||||
http.Error(w, "upload too large or malformed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
target := r.FormValue("target")
|
||||
key := strings.ToLower(strings.TrimSpace(r.FormValue("key")))
|
||||
kind := r.FormValue("kind")
|
||||
if (target != "person" && target != "family") || (kind != "photo" && kind != "pronunciation") || key == "" {
|
||||
http.Error(w, "bad upload request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
me := auth.Email(r)
|
||||
model := u.cache.Model()
|
||||
if !mayEdit(model, me, target, key) {
|
||||
http.Error(w, "not allowed to edit this record", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, "missing file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil || len(content) == 0 {
|
||||
http.Error(w, "unreadable file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
mimeType, ext, err := mediaType(kind, content, header.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
folder := "people"
|
||||
keyColumn := "Email Lower"
|
||||
column := map[string]string{"photo": "Primary Photo", "pronunciation": "Pronunciation"}[kind]
|
||||
if target == "family" {
|
||||
folder = "families"
|
||||
keyColumn = "Family Key"
|
||||
column = map[string]string{"photo": "Family Photo", "pronunciation": "Family Pronunciation"}[kind]
|
||||
}
|
||||
local, _, _ := strings.Cut(key, "@")
|
||||
base := local + "-" + kind
|
||||
name := base + "." + ext
|
||||
|
||||
archived, err := u.store.Upload(folder, base, ext, mimeType, content)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
if err := u.sheet.SetColumn(appName, "Basic Directory", keyColumn, key, column, name); err != nil {
|
||||
serverError(w, fmt.Errorf("update sheet after upload of %s/%s: %w", folder, name, err))
|
||||
return
|
||||
}
|
||||
logRow := []string{time.Now().UTC().Format(time.RFC3339), me, key, target + " " + kind, name, archived}
|
||||
if err := u.sheet.Append(appName, changeLogTable, changeLogHeader, logRow); err != nil {
|
||||
serverError(w, fmt.Errorf("append change log after upload of %s/%s: %w", folder, name, err))
|
||||
return
|
||||
}
|
||||
if err := u.cache.Refresh(); err != nil {
|
||||
serverError(w, fmt.Errorf("refresh model after upload: %w", err))
|
||||
return
|
||||
}
|
||||
log.Printf("upload: %s set %s %s %s (archived %q)", me, target, key, name, archived)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func mayEdit(model *Model, me, target, key string) bool {
|
||||
var mine *Person
|
||||
for i := range model.People {
|
||||
if model.People[i].Email == me {
|
||||
mine = &model.People[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if mine == nil {
|
||||
return false
|
||||
}
|
||||
if target == "family" {
|
||||
return mine.FamilyKey != "" && mine.FamilyKey == key
|
||||
}
|
||||
if key == me {
|
||||
return true
|
||||
}
|
||||
family, ok := model.Families[mine.FamilyKey]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, kid := range family.KidEmails {
|
||||
if kid == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mediaType(kind string, content []byte, declared string) (string, string, error) {
|
||||
if kind == "photo" {
|
||||
sniffed := http.DetectContentType(content)
|
||||
ext, ok := photoExtensions[sniffed]
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("unsupported photo type %s", sniffed)
|
||||
}
|
||||
return sniffed, ext, nil
|
||||
}
|
||||
base, _, _ := strings.Cut(declared, ";")
|
||||
base = strings.TrimSpace(strings.ToLower(base))
|
||||
ext, ok := audioExtensions[base]
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("unsupported audio type %s", declared)
|
||||
}
|
||||
if strings.HasPrefix(base, "video/") {
|
||||
base = "audio/" + strings.TrimPrefix(base, "video/")
|
||||
}
|
||||
return base, ext, nil
|
||||
}
|
||||
Reference in New Issue
Block a user