Files
ALPHA-TRAIN2/services/tiktak/server/server.go
T
bobiqqandClaude Opus 5 fac56abb1d tiktak: LFI/auth-bypass в /vtt/, утечка превью, креды
- handleVtt: id валидируется как положительное целое, ошибка GetVideo
  больше не игнорируется. Закрывает сразу два вектора: обход ACL через
  нулевой db.Video (Private=false => haveAccess пропускал анонима) и
  path traversal в path.Join(VttFolder, vid+".vtt").
- handleCreate: резкое превью приватного видео удаляется после блюра,
  иначе оно оставалось доступным в public/static через echo.Static.
- main.go: DSN приведён к паролю из compose (5935004 поменял только
  compose, из-за чего сервис не достучался бы до базы).
- db: пароли хешируются sha256 с pepper, сверка в Go constant-time,
  плейнтекст принимается как legacy => старые юзеры не теряют доступ.
- cookies: HttpOnly + Path=/ + SameSite=Lax.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:42:05 +03:00

332 lines
9.4 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())
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")
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)
}