Приходят 100 КБ webm с VP8-кадром гигантского разрешения (boundary у них буквально ----bomb*). Проверку f.Size > 5MB он проходит, validateWebm смотрит только первые 512 байт, а ffmpeg разворачивает такой кадр в ~1 ГБ сырых пикселей. При семафоре на 75 параллельных это укладывает box, чекер не укладывается в свои 7 секунд => MUMBLE. - video/probe.go: ffprobe читает geometry из заголовка (кадр не декодируется), режем > 4096 по стороне и > 8 Mpx. - Blur: image.DecodeConfig до image.Decode, тот же лимит по пикселям. imaging.Blur держит несколько копий битмапа, без лимита это OOM сам по себе. Заодно f.Close(), которого не было. - ffmpeg -threads 1, семафор 75 -> 16. - BodyLimit 8M: размер видео проверялся, а форма нет. Огромные субтитры превращались в слайс на миллионы строк в GenerateVtt. Плюс явный MaxSubtitlesSize 64K. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
339 lines
9.7 KiB
Go
339 lines
9.7 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"github.com/labstack/echo"
|
|
"github.com/labstack/echo/middleware"
|
|
"html/template"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"strconv"
|
|
"tiktak/db"
|
|
"tiktak/keygen"
|
|
"tiktak/server/auth"
|
|
"tiktak/video"
|
|
"time"
|
|
)
|
|
|
|
func NewServer(db *db.Storage, vs *video.Storage, c Config) *Server {
|
|
s := &Server{echo.New(), db, vs, c}
|
|
|
|
t := Template{template.Must(template.ParseGlob(c.TemplatesFolder + "/*.html"))}
|
|
s.e.Renderer = &t
|
|
s.e.Use(middleware.Recover())
|
|
// Hard cap on the whole request. The video file is size-checked separately,
|
|
// but nothing bounded the form fields: a huge subtitles blob becomes one
|
|
// slice entry per line inside GenerateVtt.
|
|
s.e.Use(middleware.BodyLimit("8M"))
|
|
s.e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
cc := &auth.Context{Context: c}
|
|
return next(cc)
|
|
}
|
|
})
|
|
s.routes()
|
|
return s
|
|
}
|
|
|
|
type Server struct {
|
|
e *echo.Echo
|
|
db *db.Storage
|
|
vs *video.Storage
|
|
c Config
|
|
}
|
|
|
|
func (s *Server) loginPage(c echo.Context) error {
|
|
return c.Render(200, "login", nil)
|
|
}
|
|
|
|
func (s *Server) handleLogin(c echo.Context) error {
|
|
login := c.FormValue("login")
|
|
password := c.FormValue("password")
|
|
|
|
user, err := s.db.FindUser(context.Background(), login, password)
|
|
if err != nil {
|
|
return c.Render(http.StatusUnprocessableEntity, "login", ErrorResponse{err.Error()})
|
|
}
|
|
|
|
c1, c2 := auth.Cookies(user.ID)
|
|
c.SetCookie(c1)
|
|
c.SetCookie(c2)
|
|
|
|
return c.Redirect(http.StatusFound, "/home")
|
|
}
|
|
|
|
func (s *Server) registerPage(c echo.Context) error {
|
|
return c.Render(200, "register", nil)
|
|
}
|
|
|
|
func (s *Server) handleRegister(c echo.Context) error {
|
|
login := c.FormValue("login")
|
|
password := c.FormValue("password")
|
|
if login == "" || password == "" {
|
|
return c.Render(http.StatusUnprocessableEntity, "register", ErrorResponse{"fill the form"})
|
|
}
|
|
u := db.User{Login: login, Password: password}
|
|
err := s.db.InsertUser(context.Background(), &u)
|
|
if err != nil {
|
|
return c.Render(http.StatusUnprocessableEntity, "register", ErrorResponse{err.Error()})
|
|
}
|
|
|
|
c1, c2 := auth.Cookies(u.ID)
|
|
c.SetCookie(c1)
|
|
c.SetCookie(c2)
|
|
|
|
return c.Redirect(http.StatusFound, "/home")
|
|
}
|
|
|
|
func (s *Server) createPage(c echo.Context) error {
|
|
return c.Render(http.StatusOK, "create", nil)
|
|
}
|
|
|
|
func (s *Server) handleCreate(c echo.Context) error {
|
|
ac := c.(*auth.Context)
|
|
uid := ac.GetId()
|
|
|
|
f, err := c.FormFile("video")
|
|
|
|
if err != nil {
|
|
return c.Render(http.StatusUnprocessableEntity, "create", ErrorResponse{err.Error()})
|
|
}
|
|
if f.Size > video.MaxSize {
|
|
return c.Render(http.StatusUnprocessableEntity, "create", ErrorResponse{"file is too large"})
|
|
}
|
|
vfile, err := f.Open()
|
|
if err != nil {
|
|
return c.Render(http.StatusUnprocessableEntity, "create", ErrorResponse{err.Error()})
|
|
}
|
|
|
|
link, err := s.vs.Store(vfile)
|
|
if err != nil {
|
|
return c.Render(http.StatusUnprocessableEntity, "create", ErrorResponse{"failed to store video: " + err.Error()})
|
|
}
|
|
|
|
descr := c.FormValue("description")
|
|
subt := c.FormValue("subtitles")
|
|
if len(subt) > video.MaxSubtitlesSize {
|
|
return c.Render(http.StatusUnprocessableEntity, "create", ErrorResponse{"subtitles are too long"})
|
|
}
|
|
isPrivate := c.FormValue("private")
|
|
v := db.Video{UserID: uid, Description: descr, Private: isPrivate == "on", Link: link}
|
|
|
|
ctx := context.Background()
|
|
err = s.db.AddVideo(ctx, &v)
|
|
if err != nil {
|
|
return c.Render(http.StatusUnprocessableEntity, "create", ErrorResponse{"failed to store record: " + err.Error()})
|
|
}
|
|
|
|
ppath := s.previewPath(v.ID)
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, time.Second*7)
|
|
defer cancel()
|
|
|
|
err = video.GeneratePreview(ctx, s.vs.Path(link), ppath)
|
|
if err != nil {
|
|
return c.Render(http.StatusUnprocessableEntity, "create", ErrorResponse{"failed to generate preview: " + err.Error()})
|
|
}
|
|
if err := s.storeVtt(ctx, v, subt); err != nil {
|
|
return c.Render(http.StatusUnprocessableEntity, "create", ErrorResponse{err.Error()})
|
|
}
|
|
if v.Private {
|
|
if err = video.Blur(ppath, s.privatePreviewPath(v.ID)); err != nil {
|
|
return c.Render(http.StatusUnprocessableEntity, "create", ErrorResponse{"failed to blur preview: " + err.Error()})
|
|
}
|
|
// public/static is served by echo.Static, so the sharp preview of a
|
|
// private video would otherwise be downloadable by anyone at
|
|
// /public/static/preview_<id>.png -- drop it once it has been blurred.
|
|
if err = os.Remove(ppath); err != nil && !os.IsNotExist(err) {
|
|
return c.Render(http.StatusUnprocessableEntity, "create", ErrorResponse{"failed to drop preview: " + err.Error()})
|
|
}
|
|
}
|
|
return c.Redirect(http.StatusFound, fmt.Sprintf("/watch/%d", v.ID))
|
|
}
|
|
|
|
func (s *Server) handleFeed(c echo.Context) error {
|
|
ctx := context.Background()
|
|
vds, err := s.db.ListVideo(ctx, 50)
|
|
if err != nil {
|
|
return c.JSON(http.StatusServiceUnavailable, jsonError(err.Error()))
|
|
}
|
|
resp := make([]FeedResponse, len(vds))
|
|
for i, v := range vds {
|
|
resp[i].ID = vds[i].ID
|
|
if v.Private {
|
|
resp[i].Preview = s.privatePreviewPath(v.ID)
|
|
} else {
|
|
resp[i].Preview = s.previewPath(v.ID)
|
|
}
|
|
}
|
|
return c.Render(http.StatusOK, "feed", resp)
|
|
}
|
|
|
|
func (s *Server) handleWatch(c echo.Context) error {
|
|
ac := c.(*auth.Context)
|
|
vid := c.Param("id")
|
|
ctx := context.Background()
|
|
v, err := s.db.GetVideo(ctx, vid)
|
|
if err != nil {
|
|
return c.NoContent(http.StatusNotFound)
|
|
}
|
|
uid := ac.GetId()
|
|
if s.haveAccess(ctx, uid, *v) {
|
|
resp := WatchResponse{
|
|
Description: v.Description,
|
|
VttPath: "/vtt/?id=" + vid,
|
|
VideoPath: s.vs.Path(v.Link),
|
|
}
|
|
return c.Render(http.StatusOK, "watch", resp)
|
|
}
|
|
return c.Render(http.StatusForbidden, "access", AccessResponse{ID: int(v.ID)})
|
|
}
|
|
|
|
func (s *Server) handleVtt(c echo.Context) error {
|
|
ac := c.(*auth.Context)
|
|
|
|
// The id must be a plain positive integer. Anything else (./7, x/../7,
|
|
// ../../etc/foo) is rejected before it can reach either the DB or the FS.
|
|
vid, err := strconv.ParseInt(c.QueryParam("id"), 10, 64)
|
|
if err != nil || vid <= 0 {
|
|
return c.NoContent(http.StatusNotFound)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
v, err := s.db.GetVideo(ctx, vid)
|
|
// The error MUST be handled: a missing row leaves a zero db.Video
|
|
// (UserID=0, Private=false) which haveAccess() would happily accept.
|
|
if err != nil || v == nil {
|
|
return c.NoContent(http.StatusNotFound)
|
|
}
|
|
|
|
if !s.haveAccess(ctx, ac.GetId(), *v) {
|
|
return c.NoContent(http.StatusForbidden)
|
|
}
|
|
|
|
// Build the path from the validated integer, never from raw user input.
|
|
vttPath := path.Join(s.c.VttFolder, strconv.FormatInt(vid, 10)+".vtt")
|
|
return c.File(vttPath)
|
|
}
|
|
|
|
func (s *Server) handleAccess(c echo.Context) error {
|
|
ac := c.(*auth.Context)
|
|
vidString := c.FormValue("videoID")
|
|
vid, err := strconv.Atoi(vidString)
|
|
if err != nil {
|
|
return c.Render(http.StatusUnprocessableEntity, "access", AccessResponse{
|
|
ErrorResponse: ErrorResponse{"bad video id: " + err.Error()},
|
|
ID: 0,
|
|
})
|
|
}
|
|
|
|
key := c.FormValue("token")
|
|
if keygen.ValidateKey(key, vid) {
|
|
ctx := context.Background()
|
|
if err := s.db.AddAccess(ctx, vid, ac.GetId()); err != nil {
|
|
return c.Render(http.StatusServiceUnavailable, "access", AccessResponse{
|
|
ErrorResponse: ErrorResponse{"try again later: " + err.Error()},
|
|
ID: vid,
|
|
})
|
|
}
|
|
return c.Redirect(http.StatusFound, "/watch/"+strconv.Itoa(vid))
|
|
}
|
|
return c.Render(http.StatusUnprocessableEntity, "access", AccessResponse{
|
|
ErrorResponse: ErrorResponse{"Bad token"},
|
|
ID: vid,
|
|
})
|
|
|
|
}
|
|
|
|
func (s *Server) homePage(c echo.Context) error {
|
|
ac := c.(*auth.Context)
|
|
vds, err := s.db.ListUserVideo(context.Background(), ac.GetId())
|
|
if err != nil {
|
|
return c.Render(http.StatusServiceUnavailable, "home", ErrorResponse{Error: err.Error()})
|
|
}
|
|
vresp := make([]VideoResponse, len(vds))
|
|
for i, v := range vds {
|
|
vresp[i].ID = v.ID
|
|
vresp[i].Private = v.Private
|
|
vresp[i].Description = v.Description
|
|
if v.Private {
|
|
vresp[i].Token = keygen.GenerateKey(int(v.ID))
|
|
vresp[i].Preview = s.privatePreviewPath(v.ID)
|
|
} else {
|
|
vresp[i].Preview = s.previewPath(v.ID)
|
|
}
|
|
}
|
|
return c.Render(http.StatusOK, "home", vresp)
|
|
}
|
|
|
|
func (s *Server) indexPage(c echo.Context) error {
|
|
ac := c.(*auth.Context)
|
|
if ac.GetId() != 0 {
|
|
return c.Redirect(http.StatusFound, "/home")
|
|
}
|
|
return c.Redirect(http.StatusFound, "/login")
|
|
}
|
|
|
|
func (s *Server) haveAccess(ctx context.Context, uid int64, v db.Video) bool {
|
|
if v.UserID == uid {
|
|
return true
|
|
}
|
|
if !v.Private {
|
|
return true
|
|
}
|
|
return s.db.HaveAccess(ctx, v.ID, uid)
|
|
}
|
|
|
|
func (s *Server) storeVtt(ctx context.Context, v db.Video, subtitles string) error {
|
|
vtt, err := video.GenerateVtt(ctx, s.vs.Path(v.Link), subtitles)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
f, err := os.Create(path.Join(s.c.VttFolder, fmt.Sprintf("%d.vtt", v.ID)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
fmt.Fprint(f, "WEBVTT Kind: captions; Language: ctf\n\n")
|
|
for _, line := range vtt {
|
|
fmt.Fprintln(f, line)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) previewPath(vid int64) string {
|
|
return path.Join(s.c.StaticFolder, fmt.Sprintf("preview_%d.png", vid))
|
|
}
|
|
|
|
func (s *Server) privatePreviewPath(vid int64) string {
|
|
return path.Join(s.c.StaticFolder, fmt.Sprintf("preview_%d_blured.png", vid))
|
|
}
|
|
|
|
func (s *Server) routes() {
|
|
s.e.GET("/login", s.loginPage)
|
|
s.e.POST("/login", s.handleLogin)
|
|
s.e.GET("/register", s.registerPage)
|
|
s.e.POST("/register", s.handleRegister)
|
|
s.e.GET("/create", s.createPage, auth.RequiredMiddleware)
|
|
s.e.POST("/create", s.handleCreate, auth.RequiredMiddleware)
|
|
s.e.GET("/feed", s.handleFeed)
|
|
s.e.GET("/watch/:id", s.handleWatch)
|
|
s.e.POST("/access", s.handleAccess, auth.RequiredMiddleware)
|
|
s.e.GET("/vtt/", s.handleVtt)
|
|
s.e.GET("/home", s.homePage, auth.RequiredMiddleware)
|
|
s.e.Static("/"+s.vs.Directory, s.vs.Directory)
|
|
s.e.Static("/"+s.c.StaticFolder, s.c.StaticFolder)
|
|
s.e.GET("/", s.indexPage)
|
|
}
|
|
|
|
func (s *Server) Shutdown(ctx context.Context) error {
|
|
return s.e.Shutdown(ctx)
|
|
}
|
|
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
s.e.ServeHTTP(w, r)
|
|
}
|