From 8be3741c9e6aff7ef70c13eb4d9b4c7e58ca185d Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Sun, 26 Jul 2026 01:26:25 +0000 Subject: [PATCH] feat: upload document slides --- README.md | 3 +- internal/coreclient/client.go | 23 ++++- internal/coreclient/client_test.go | 49 +++++++++++ internal/upload/handlers.go | 91 +++++++++++++++++-- internal/upload/handlers_test.go | 136 +++++++++++++++++++++++++++-- internal/upload/service.go | 40 ++++++--- internal/upload/service_test.go | 23 +++-- internal/upload/validate.go | 40 +++++++-- internal/upload/validate_test.go | 23 +++++ internal/web/assets/tailwind.css | 57 ++++++++++++ internal/web/page_upload.templ | 43 +++++---- internal/web/page_upload_templ.go | 66 ++++++++++---- internal/web/static/css/app.css | 2 +- internal/web/static/js/upload.js | 133 +++++++++++++++++++++++++++- internal/web/web_test.go | 17 +++- 15 files changed, 659 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index ecee9c9..be0ea3d 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,8 @@ YouTube-лекции, карточка лекции покажет ошибку тело `application/json` `{filename}`); возвращает `{Key, URL, ExpiresIn}`. - `CreateTask(ctx, params)` — создание задачи (`POST /tasks`, **multipart/form-data**); ровно один источник — `S3Key` или `VideoURL`, плюс - опциональные `Media` и `NoSlides`. multipart-тело собирается вручную, т.к. + опциональные `Media`, `NoSlides` и презентация `SlidesName`/`SlidesContent` + (поле API `slides`, PDF/PPTX). multipart-тело собирается вручную, т.к. oapi-codegen для multipart даёт только сырой `…WithBodyWithResponse`. - `GetTaskStatus(ctx, taskID)` — статус задачи (`GET /tasks/{id}`); на 404 возвращает `ErrTaskNotFound`. Nullable-поля (`Stage`, `Error`, `ErrorCode`, diff --git a/internal/coreclient/client.go b/internal/coreclient/client.go index 282e5a1..b6fdc89 100644 --- a/internal/coreclient/client.go +++ b/internal/coreclient/client.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "io" "mime/multipart" "net/http" "strconv" @@ -34,10 +35,12 @@ type UploadResult struct { // Должен быть задан РОВНО ОДИН источник: S3Key ИЛИ VideoURL. Сценарии B1 // покрывают именно эти два источника (audio/video-файлы напрямую — вне scope). type CreateTaskParams struct { - S3Key string // источник: ключ уже загруженного объекта (uploads/...) - VideoURL string // источник: URL видео (напр. ссылка YouTube) - Media string // опционально: "audio" | "video" (по умолчанию на стороне ядра — audio) - NoSlides bool // опционально: отключить извлечение слайдов + S3Key string // источник: ключ уже загруженного объекта (uploads/...) + VideoURL string // источник: URL видео (напр. ссылка YouTube) + Media string // опционально: "audio" | "video" (по умолчанию на стороне ядра — audio) + NoSlides bool // опционально: отключить извлечение слайдов + SlidesName string // имя приложенного PDF/PPTX + SlidesContent io.Reader // содержимое приложенного PDF/PPTX } // CreateUpload запрашивает у ядра presigned-PUT URL для загрузки контента. @@ -77,6 +80,9 @@ func (c *CoreClient) CreateTask(ctx context.Context, params CreateTaskParams) (s if hasS3 == hasURL { return "", fmt.Errorf("coreclient: нужен ровно один источник (s3_key ИЛИ video_url)") } + if (params.SlidesContent == nil) != (params.SlidesName == "") { + return "", fmt.Errorf("coreclient: slides требуют одновременно имя и содержимое") + } var buf bytes.Buffer w := multipart.NewWriter(&buf) @@ -100,6 +106,15 @@ func (c *CoreClient) CreateTask(ctx context.Context, params CreateTaskParams) (s return "", fmt.Errorf("coreclient: запись no_slides: %w", err) } } + if params.SlidesContent != nil { + part, err := w.CreateFormFile("slides", params.SlidesName) + if err != nil { + return "", fmt.Errorf("coreclient: создание части slides: %w", err) + } + if _, err := io.Copy(part, params.SlidesContent); err != nil { + return "", fmt.Errorf("coreclient: запись slides: %w", err) + } + } if err := w.Close(); err != nil { return "", fmt.Errorf("coreclient: закрытие multipart: %w", err) } diff --git a/internal/coreclient/client_test.go b/internal/coreclient/client_test.go index 9f5fc01..112662b 100644 --- a/internal/coreclient/client_test.go +++ b/internal/coreclient/client_test.go @@ -20,6 +20,8 @@ type mockCore struct { // Захваченные данные последнего запроса для ассертов в тестах. lastUploadFilename string lastTaskForm map[string]string + lastSlidesFilename string + lastSlidesBody []byte lastTaskCT string lastCookieCT string lastCookieBody []byte @@ -80,6 +82,16 @@ func newMockCore(t *testing.T) *mockCore { m.lastTaskForm[k] = v[0] } } + if files := r.MultipartForm.File["slides"]; len(files) > 0 { + m.lastSlidesFilename = files[0].Filename + file, err := files[0].Open() + if err != nil { + http.Error(w, "bad slides", http.StatusBadRequest) + return + } + m.lastSlidesBody, _ = io.ReadAll(file) + _ = file.Close() + } // Ветка 400: нет ни одного источника. _, hasS3 := m.lastTaskForm["s3_key"] @@ -239,6 +251,30 @@ func TestCreateTask_VideoURL(t *testing.T) { } } +func TestCreateTask_WithDocumentSlides(t *testing.T) { + m := newMockCore(t) + c := newTestClient(t, m.srv.URL) + + id, err := c.CreateTask(context.Background(), CreateTaskParams{ + VideoURL: "https://youtu.be/x", + NoSlides: true, + SlidesName: "deck.pptx", + SlidesContent: strings.NewReader("pptx-content"), + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + if id != "task-123" { + t.Errorf("task id = %q, want task-123", id) + } + if m.lastSlidesFilename != "deck.pptx" { + t.Errorf("slides filename = %q, want deck.pptx", m.lastSlidesFilename) + } + if string(m.lastSlidesBody) != "pptx-content" { + t.Errorf("slides body = %q, want pptx-content", m.lastSlidesBody) + } +} + func TestCreateTask_NoSource(t *testing.T) { m := newMockCore(t) c := newTestClient(t, m.srv.URL) @@ -249,6 +285,19 @@ func TestCreateTask_NoSource(t *testing.T) { } } +func TestCreateTask_SlidesRequireNameAndContent(t *testing.T) { + m := newMockCore(t) + c := newTestClient(t, m.srv.URL) + + _, err := c.CreateTask(context.Background(), CreateTaskParams{ + VideoURL: "https://youtu.be/x", + SlidesContent: strings.NewReader("pdf"), + }) + if err == nil { + t.Fatal("expected error for slides without filename") + } +} + func TestGetTaskStatus(t *testing.T) { m := newMockCore(t) c := newTestClient(t, m.srv.URL) diff --git a/internal/upload/handlers.go b/internal/upload/handlers.go index 7fd9430..c9f4933 100644 --- a/internal/upload/handlers.go +++ b/internal/upload/handlers.go @@ -5,7 +5,9 @@ import ( "errors" "io" "log" + "mime/multipart" "net/http" + "strings" "github.com/LectureLog/lecturelog-web/internal/auth" "github.com/go-chi/chi/v5" @@ -79,16 +81,25 @@ func (s *Service) handleConfirm(w http.ResponseWriter, r *http.Request) { return } - if err := r.ParseForm(); err != nil { - http.Error(w, "неверный запрос", http.StatusBadRequest) + if !parseTaskForm(w, r) { + return + } + defer cleanupMultipartForm(r) + + slides, slidesFile, err := slidesFromRequest(r) + if err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) return } + if slidesFile != nil { + defer slidesFile.Close() + } - _, err := s.ConfirmFileUpload(r.Context(), user.ID, ConfirmInput{ + _, err = s.ConfirmFileUpload(r.Context(), user.ID, ConfirmInput{ Token: r.FormValue("token"), S3Key: r.FormValue("s3_key"), Title: r.FormValue("title"), - HasPDF: parseUploadCheckbox(r.FormValue("has_pdf")), + Slides: slides, ExtractSlides: parseUploadCheckbox(r.FormValue("extract_slides")), }) if err != nil { @@ -117,15 +128,24 @@ func (s *Service) handleYouTube(w http.ResponseWriter, r *http.Request) { return } - if err := r.ParseForm(); err != nil { - http.Error(w, "неверный запрос", http.StatusBadRequest) + if !parseTaskForm(w, r) { return } + defer cleanupMultipartForm(r) - _, err := s.CreateYouTube(r.Context(), user.ID, YouTubeInput{ + slides, slidesFile, err := slidesFromRequest(r) + if err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + if slidesFile != nil { + defer slidesFile.Close() + } + + _, err = s.CreateYouTube(r.Context(), user.ID, YouTubeInput{ URL: r.FormValue("url"), Title: r.FormValue("title"), - HasPDF: parseUploadCheckbox(r.FormValue("has_pdf")), + Slides: slides, ExtractSlides: parseUploadCheckbox(r.FormValue("extract_slides")), }) if err != nil { @@ -151,8 +171,61 @@ func uploadErrStatus(err error) int { errors.Is(err, ErrTooLarge) || errors.Is(err, ErrEmptyFilename) || errors.Is(err, ErrMediaMismatch) || - errors.Is(err, ErrInvalidURL) { + errors.Is(err, ErrInvalidURL) || + errors.Is(err, ErrSlidesRequired) || + errors.Is(err, ErrUnsupportedSlides) || + errors.Is(err, ErrSlidesTooLarge) { return http.StatusUnprocessableEntity } return http.StatusInternalServerError } + +func parseTaskForm(w http.ResponseWriter, r *http.Request) bool { + if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { + if err := r.ParseForm(); err != nil { + http.Error(w, "неверный запрос", http.StatusBadRequest) + return false + } + return true + } + r.Body = http.MaxBytesReader(w, r.Body, maxSlidesBytes+(1<<20)) + if err := r.ParseMultipartForm(1 << 20); err != nil { + if errors.As(err, new(*http.MaxBytesError)) { + http.Error(w, ErrSlidesTooLarge.Error(), http.StatusUnprocessableEntity) + } else { + http.Error(w, "неверный запрос", http.StatusBadRequest) + } + return false + } + return true +} + +func slidesFromRequest(r *http.Request) (*SlidesUpload, multipart.File, error) { + if r.MultipartForm == nil { + if parseUploadCheckbox(r.FormValue("has_pdf")) { + return nil, nil, ErrSlidesRequired + } + return nil, nil, nil + } + file, header, err := r.FormFile("slides") + if errors.Is(err, http.ErrMissingFile) { + if parseUploadCheckbox(r.FormValue("has_pdf")) { + return nil, nil, ErrSlidesRequired + } + return nil, nil, nil + } + if err != nil { + return nil, nil, ErrSlidesRequired + } + if err := ValidateSlidesMeta(header.Filename, header.Size); err != nil { + _ = file.Close() + return nil, nil, err + } + return &SlidesUpload{Filename: header.Filename, Content: file}, file, nil +} + +func cleanupMultipartForm(r *http.Request) { + if r.MultipartForm != nil { + _ = r.MultipartForm.RemoveAll() + } +} diff --git a/internal/upload/handlers_test.go b/internal/upload/handlers_test.go index 62a8fbd..5727f4c 100644 --- a/internal/upload/handlers_test.go +++ b/internal/upload/handlers_test.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "encoding/json" + "io" + "mime/multipart" "net/http" "net/http/httptest" "net/url" @@ -239,11 +241,56 @@ func TestConfirm_ExtractSlidesUnchecked(t *testing.T) { assertConfirmNoSlidesFromForm(t, url.Values{}, true) } -func TestConfirm_HasPDFForcesNoSlides(t *testing.T) { - assertConfirmNoSlidesFromForm(t, url.Values{ - "has_pdf": {"on"}, - "extract_slides": {"on"}, - }, true) +func TestConfirm_HasPDFWithoutFileRejected(t *testing.T) { + s3Key := "uploads/user-test-uuid/lecture.mp4" + signer := newTestServiceSigner() + handler := mountTestRouter(NewService(&mockCore{}, &mockRepo{}, signer, time.Hour)) + form := url.Values{ + "token": {signer.Sign(testUser.ID, s3Key, "video", time.Hour)}, + "s3_key": {s3Key}, + "title": {"Lecture"}, + "has_pdf": {"on"}, + } + req := addSessionCookie(newFormRequest(http.MethodPost, "/upload/confirm", form)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("POST /upload/confirm without slides = %d, want 422", rec.Code) + } +} + +func TestConfirm_WithSlidesPassesDocumentToCore(t *testing.T) { + s3Key := "uploads/user-test-uuid/lecture.mp4" + signer := newTestServiceSigner() + core := &mockCore{ + createTaskFunc: func(_ context.Context, p coreclient.CreateTaskParams) (string, error) { + if p.SlidesName != "deck.pdf" { + t.Fatalf("SlidesName = %q, want deck.pdf", p.SlidesName) + } + got, err := io.ReadAll(p.SlidesContent) + if err != nil || string(got) != "%PDF-test" { + t.Fatalf("SlidesContent = %q, err=%v", got, err) + } + if p.NoSlides { + t.Fatal("NoSlides = true would disable the attached document in core") + } + return "task-confirm", nil + }, + } + handler := mountTestRouter(NewService(core, &mockRepo{}, signer, time.Hour)) + fields := url.Values{ + "token": {signer.Sign(testUser.ID, s3Key, "video", time.Hour)}, + "s3_key": {s3Key}, + "title": {"Lecture"}, + "has_pdf": {"true"}, + "extract_slides": {"true"}, + } + req := addSessionCookie(newMultipartRequest(t, http.MethodPost, "/upload/confirm", fields, "deck.pdf", "%PDF-test")) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("POST /upload/confirm with slides = %d, want 200: %s", rec.Code, rec.Body.String()) + } } func TestConfirm_ExtractSlidesCheckedEnablesSlides(t *testing.T) { @@ -337,12 +384,91 @@ func TestYouTube_BadURL(t *testing.T) { } } +func TestYouTube_WithSlidesPassesDocumentToCore(t *testing.T) { + core := &mockCore{ + createTaskFunc: func(_ context.Context, p coreclient.CreateTaskParams) (string, error) { + if p.VideoURL != "https://youtu.be/video" || p.SlidesName != "deck.pptx" { + t.Fatalf("params = %+v", p) + } + got, err := io.ReadAll(p.SlidesContent) + if err != nil || string(got) != "pptx-test" { + t.Fatalf("SlidesContent = %q, err=%v", got, err) + } + if p.NoSlides { + t.Fatal("NoSlides = true would disable the attached document") + } + return "task-youtube", nil + }, + } + handler := mountTestRouter(newTestHTTPService(core, &mockRepo{})) + fields := url.Values{ + "url": {"https://youtu.be/video"}, + "title": {"Lecture"}, + "has_pdf": {"true"}, + } + req := addSessionCookie(newMultipartRequest(t, http.MethodPost, "/upload/youtube", fields, "deck.pptx", "pptx-test")) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("POST /upload/youtube with slides = %d, want 200: %s", rec.Code, rec.Body.String()) + } +} + +func TestYouTube_RejectsUnsupportedSlides(t *testing.T) { + handler := mountTestRouter(newTestHTTPService(&mockCore{}, &mockRepo{})) + fields := url.Values{ + "url": {"https://youtu.be/video"}, + "has_pdf": {"true"}, + } + req := addSessionCookie(newMultipartRequest(t, http.MethodPost, "/upload/youtube", fields, "deck.key", "key-test")) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("POST /upload/youtube unsupported slides = %d, want 422", rec.Code) + } +} + func newFormRequest(method, target string, form url.Values) *http.Request { req := httptest.NewRequest(method, target, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") return req } +func newMultipartRequest( + t *testing.T, + method string, + target string, + fields url.Values, + filename string, + content string, +) *http.Request { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + for key, values := range fields { + for _, value := range values { + if err := writer.WriteField(key, value); err != nil { + t.Fatal(err) + } + } + } + if filename != "" { + part, err := writer.CreateFormFile("slides", filename) + if err != nil { + t.Fatal(err) + } + if _, err := part.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(method, target, &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + return req +} + func assertConfirmNoSlidesFromForm(t *testing.T, fields url.Values, want bool) { t.Helper() diff --git a/internal/upload/service.go b/internal/upload/service.go index 55f99c9..f0ff398 100644 --- a/internal/upload/service.go +++ b/internal/upload/service.go @@ -3,6 +3,7 @@ package upload import ( "context" "errors" + "io" "path/filepath" "strings" "time" @@ -43,14 +44,14 @@ type ConfirmInput struct { Token string S3Key string Title string - HasPDF bool + Slides *SlidesUpload ExtractSlides bool } type YouTubeInput struct { URL string Title string - HasPDF bool + Slides *SlidesUpload ExtractSlides bool } @@ -98,9 +99,11 @@ func (s *Service) ConfirmFileUpload(ctx context.Context, userID string, in Confi } taskID, err := s.core.CreateTask(ctx, coreclient.CreateTaskParams{ - S3Key: in.S3Key, - Media: media, - NoSlides: noSlides(in.HasPDF, in.ExtractSlides), + S3Key: in.S3Key, + Media: media, + NoSlides: noSlides(in.Slides != nil, in.ExtractSlides), + SlidesName: slidesName(in.Slides), + SlidesContent: slidesContent(in.Slides), }) if err != nil { return "", err @@ -120,11 +123,11 @@ func (s *Service) CreateYouTube(ctx context.Context, userID string, in YouTubeIn return "", err } - // Долг: PDF-слайды пока не передаются в ядро, потому что CreateTaskParams - // не принимает файл слайдов; HasPDF только отключает извлечение слайдов. taskID, err := s.core.CreateTask(ctx, coreclient.CreateTaskParams{ - VideoURL: in.URL, - NoSlides: noSlides(in.HasPDF, in.ExtractSlides), + VideoURL: in.URL, + NoSlides: noSlides(in.Slides != nil, in.ExtractSlides), + SlidesName: slidesName(in.Slides), + SlidesContent: slidesContent(in.Slides), }) if err != nil { return "", err @@ -139,11 +142,22 @@ func (s *Service) CreateYouTube(ctx context.Context, userID string, in YouTubeIn }) } -func noSlides(hasPDF, extractSlides bool) bool { - if hasPDF { - return true +func noSlides(hasDocument, extractSlides bool) bool { + return !hasDocument && !extractSlides +} + +func slidesName(slides *SlidesUpload) string { + if slides == nil { + return "" + } + return filepath.Base(slides.Filename) +} + +func slidesContent(slides *SlidesUpload) io.Reader { + if slides == nil { + return nil } - return !extractSlides + return slides.Content } func titleFromFilename(filename string) string { diff --git a/internal/upload/service_test.go b/internal/upload/service_test.go index 426047f..0d743bd 100644 --- a/internal/upload/service_test.go +++ b/internal/upload/service_test.go @@ -3,6 +3,7 @@ package upload import ( "context" "errors" + "strings" "testing" "time" @@ -188,16 +189,19 @@ func TestConfirmFileUpload_CoreError(t *testing.T) { } } -func TestConfirmFileUpload_PDFForcesNoSlides(t *testing.T) { - assertConfirmNoSlides(t, ConfirmInput{HasPDF: true, ExtractSlides: true}, true) +func TestConfirmFileUpload_DocumentKeepsSlidesEnabled(t *testing.T) { + assertConfirmNoSlides(t, ConfirmInput{ + Slides: &SlidesUpload{Filename: "deck.pdf", Content: strings.NewReader("pdf")}, + ExtractSlides: true, + }, false) } func TestConfirmFileUpload_ExtractToggleOff(t *testing.T) { - assertConfirmNoSlides(t, ConfirmInput{HasPDF: false, ExtractSlides: false}, true) + assertConfirmNoSlides(t, ConfirmInput{ExtractSlides: false}, true) } func TestConfirmFileUpload_ExtractOn(t *testing.T) { - assertConfirmNoSlides(t, ConfirmInput{HasPDF: false, ExtractSlides: true}, false) + assertConfirmNoSlides(t, ConfirmInput{ExtractSlides: true}, false) } func TestCreateYouTube_Success(t *testing.T) { @@ -258,15 +262,18 @@ func TestCreateYouTube_Success(t *testing.T) { } func TestCreateYouTube_ExtractSlidesOn(t *testing.T) { - assertYouTubeNoSlides(t, YouTubeInput{HasPDF: false, ExtractSlides: true}, false) + assertYouTubeNoSlides(t, YouTubeInput{ExtractSlides: true}, false) } -func TestCreateYouTube_PDFForcesNoSlides(t *testing.T) { - assertYouTubeNoSlides(t, YouTubeInput{HasPDF: true, ExtractSlides: true}, true) +func TestCreateYouTube_DocumentKeepsSlidesEnabled(t *testing.T) { + assertYouTubeNoSlides(t, YouTubeInput{ + Slides: &SlidesUpload{Filename: "deck.pptx", Content: strings.NewReader("pptx")}, + ExtractSlides: true, + }, false) } func TestCreateYouTube_NoSlidesAtAll(t *testing.T) { - assertYouTubeNoSlides(t, YouTubeInput{HasPDF: false, ExtractSlides: false}, true) + assertYouTubeNoSlides(t, YouTubeInput{ExtractSlides: false}, true) } func TestCreateYouTube_InvalidURL(t *testing.T) { diff --git a/internal/upload/validate.go b/internal/upload/validate.go index d04b4c6..a71754d 100644 --- a/internal/upload/validate.go +++ b/internal/upload/validate.go @@ -2,6 +2,7 @@ package upload import ( "errors" + "io" "net/url" "path/filepath" "strings" @@ -15,17 +16,44 @@ const ( // Грубый потолок беты. maxUploadBytes int64 = 5 << 30 + maxSlidesBytes int64 = 100 << 20 ) var ( - ErrUnsupportedMedia = errors.New("неподдерживаемый тип медиа") - ErrEmptyFile = errors.New("пустой файл") - ErrTooLarge = errors.New("файл слишком большой") - ErrEmptyFilename = errors.New("пустое имя файла") - ErrInvalidURL = errors.New("некорректная ссылка") - ErrMediaMismatch = errors.New("тип медиа не соответствует расширению файла") + ErrUnsupportedMedia = errors.New("неподдерживаемый тип медиа") + ErrEmptyFile = errors.New("пустой файл") + ErrTooLarge = errors.New("файл слишком большой") + ErrEmptyFilename = errors.New("пустое имя файла") + ErrInvalidURL = errors.New("некорректная ссылка") + ErrMediaMismatch = errors.New("тип медиа не соответствует расширению файла") + ErrSlidesRequired = errors.New("выберите файл презентации") + ErrUnsupportedSlides = errors.New("поддерживаются только PDF и PPTX") + ErrSlidesTooLarge = errors.New("презентация слишком большая") ) +type SlidesUpload struct { + Filename string + Content io.Reader +} + +func ValidateSlidesMeta(filename string, size int64) error { + if filename == "" { + return ErrSlidesRequired + } + switch strings.ToLower(filepath.Ext(filename)) { + case ".pdf", ".pptx": + default: + return ErrUnsupportedSlides + } + if size <= 0 { + return ErrEmptyFile + } + if size > maxSlidesBytes { + return ErrSlidesTooLarge + } + return nil +} + func DetectMedia(filename string) (media string, ok bool) { switch strings.ToLower(filepath.Ext(filename)) { case ".mp4", ".mov", ".mkv", ".webm", ".avi": diff --git a/internal/upload/validate_test.go b/internal/upload/validate_test.go index b84ed19..83aa8e5 100644 --- a/internal/upload/validate_test.go +++ b/internal/upload/validate_test.go @@ -89,3 +89,26 @@ func TestValidateYouTubeURL(t *testing.T) { }) } } + +func TestValidateSlidesMeta(t *testing.T) { + tests := []struct { + name string + filename string + size int64 + wantErr error + }{ + {name: "pdf", filename: "deck.pdf", size: 10, wantErr: nil}, + {name: "pptx uppercase", filename: "DECK.PPTX", size: 10, wantErr: nil}, + {name: "unsupported", filename: "deck.key", size: 10, wantErr: ErrUnsupportedSlides}, + {name: "empty", filename: "deck.pdf", size: 0, wantErr: ErrEmptyFile}, + {name: "too large", filename: "deck.pdf", size: maxSlidesBytes + 1, wantErr: ErrSlidesTooLarge}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateSlidesMeta(tt.filename, tt.size) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("ValidateSlidesMeta() error = %v, want %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/web/assets/tailwind.css b/internal/web/assets/tailwind.css index 293f3eb..fbc25b9 100644 --- a/internal/web/assets/tailwind.css +++ b/internal/web/assets/tailwind.css @@ -999,6 +999,63 @@ outline-offset: 2px; } + .ll-upload-slides-option { + display: grid; + gap: 12px; + } + + .ll-upload-slides { + margin-left: 37px; + padding: 14px 16px; + border: 1px dashed var(--border-2); + border-radius: var(--r-card); + background: var(--surface-2); + } + + .ll-upload-slides-pick, + .ll-upload-slides-file button { + border: 1px solid var(--border-2); + border-radius: var(--r-btn); + background: var(--surface); + color: var(--ink); + font: 600 13px var(--font-sans); + cursor: pointer; + } + + .ll-upload-slides-pick { + min-height: 38px; + padding: 0 16px; + } + + .ll-upload-slides-file { + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 13px; + color: var(--ink); + } + + .ll-upload-slides-file:not(.ll-upload-hidden) { + display: flex; + } + + .ll-upload-slides-file span { + min-width: 0; + overflow-wrap: anywhere; + } + + .ll-upload-slides-file button { + flex: none; + padding: 7px 11px; + color: var(--text-2); + } + + .ll-upload-slides > p { + margin: 8px 0 0; + font-size: 12px; + color: var(--text-3); + } + .ll-upload-opt-title, .ll-upload-opt-text { display: block; diff --git a/internal/web/page_upload.templ b/internal/web/page_upload.templ index ca81046..8dc063b 100644 --- a/internal/web/page_upload.templ +++ b/internal/web/page_upload.templ @@ -50,14 +50,7 @@ templ UploadPage(data LayoutData) {
- + @slidesUploadOption()
-
+

Поддерживаются видео с YouTube и из публичных постов X.

+
- + @slidesUploadOption()
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = iconUploadPlay().Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = slidesUploadOption().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
Конспект появится в «Моих лекциях» — обработка идёт на сервере, страницу можно закрыть.
Сгенерировать конспект Конспект появится в «Моих лекциях» — обработка идёт на сервере, страницу можно закрыть.