Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
23 changes: 19 additions & 4 deletions internal/coreclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"strconv"
Expand Down Expand Up @@ -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 для загрузки контента.
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down
49 changes: 49 additions & 0 deletions internal/coreclient/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ type mockCore struct {
// Захваченные данные последнего запроса для ассертов в тестах.
lastUploadFilename string
lastTaskForm map[string]string
lastSlidesFilename string
lastSlidesBody []byte
lastTaskCT string
lastCookieCT string
lastCookieBody []byte
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
91 changes: 82 additions & 9 deletions internal/upload/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
}
}
Loading