diff --git a/internal/cmd/home/home.go b/internal/cmd/home/home.go new file mode 100644 index 0000000..fa91a4c --- /dev/null +++ b/internal/cmd/home/home.go @@ -0,0 +1,225 @@ +package home + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "io/fs" + "log" + "net" + "net/http" + "os" + "strings" + "time" + + "golang.org/x/text/language" + + "source.toby3d.me/toby3d/home/internal/common" + "source.toby3d.me/toby3d/home/internal/domain" + "source.toby3d.me/toby3d/home/internal/middleware" + "source.toby3d.me/toby3d/home/internal/page" + pagefsrepo "source.toby3d.me/toby3d/home/internal/page/repository/fs" + pageucase "source.toby3d.me/toby3d/home/internal/page/usecase" + resourcefsrepo "source.toby3d.me/toby3d/home/internal/resource/repository/fs" + resourceucase "source.toby3d.me/toby3d/home/internal/resource/usecase" + sitefsrepo "source.toby3d.me/toby3d/home/internal/site/repository/fs" + siteucase "source.toby3d.me/toby3d/home/internal/site/usecase" + staticfsrepo "source.toby3d.me/toby3d/home/internal/static/repository/fs" + staticucase "source.toby3d.me/toby3d/home/internal/static/usecase" + "source.toby3d.me/toby3d/home/internal/templateutil" + themefsrepo "source.toby3d.me/toby3d/home/internal/theme/repository/fs" + themeucase "source.toby3d.me/toby3d/home/internal/theme/usecase" + "source.toby3d.me/toby3d/home/internal/urlutil" +) + +type ( + App struct { + server *http.Server + } + + // TODO(toby3d): move this into separated package. + Context struct { + Site *domain.Site + Page *domain.Page + } +) + +func NewApp(logger *log.Logger, config *domain.Config) (*App, error) { + themeDir := os.DirFS(config.ThemeDir) + contentDir := os.DirFS(config.ContentDir) + resources := resourcefsrepo.NewFileServerResourceRepository(contentDir) + sites := sitefsrepo.NewFileSystemSiteRepository(contentDir) + siter := siteucase.NewSiteUseCase(sites, resources) + + funcMap, err := templateutil.New(themeDir, siter) + if err != nil { + return nil, fmt.Errorf("cannot setup template.FuncMap for templates: %w", err) + } + + staticDir := os.DirFS(config.StaticDir) + statics := staticfsrepo.NewFileServerStaticRepository(staticDir) + staticer := staticucase.NewStaticUseCase(statics) + resourcer := resourceucase.NewResourceUseCase(resources) + themes := themefsrepo.NewFileSystemThemeRepository(themeDir, funcMap) + themer := themeucase.NewThemeUseCase(themes) + pages := pagefsrepo.NewFileSystemPageRepository(contentDir) + pager := pageucase.NewPageUseCase(pages, resources) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // INFO(toby3d): any static file is public and unprotected by design, so it's safe to search it + // first before deep down to any page or it's resource which might be secured by middleware or + // something else. + static, err := staticer.Do(r.Context(), strings.TrimPrefix(r.URL.Path, "/")) + if err == nil { + http.ServeContent(w, r, static.Name(), static.ModTime(), static) + + return + } + + lang := domain.LanguageUnd + + s, err := siter.Do(r.Context(), lang) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + + if s.IsMultiLingual() { + head, tail := urlutil.ShiftPath(r.URL.Path) + + if head == "" { + supported := make([]language.Tag, len(s.Languages)) + for i := range s.Languages { + supported[i] = language.Make(s.Languages[i].Lang()) + } + + if s.DefaultLanguage != domain.LanguageUnd { + supported = append( + []language.Tag{language.Make(s.DefaultLanguage.Code())}, supported..., + ) + } + + requested, _, err := language.ParseAcceptLanguage( + r.Header.Get(common.HeaderAcceptLanguage)) + if err != nil || len(requested) == 0 { + requested = append(requested, language.English) + } + + matched, _, _ := language.NewMatcher(supported).Match(requested...) + lang = domain.NewLanguage(matched.String()) + + http.Redirect(w, r, "/"+lang.Lang()+"/", http.StatusSeeOther) + + return + } + + lang = domain.NewLanguage(head) + r.URL.Path = tail + } + + if s, err = siter.Do(r.Context(), lang); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + + p, err := pager.Do(r.Context(), lang, r.URL.Path) + if err != nil { + if !errors.Is(err, page.ErrNotExist) { + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + + res, err := resourcer.Do(r.Context(), r.URL.Path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + http.Error(w, err.Error(), http.StatusNotFound) + + return + } + + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + + // TODO(toby3d) : ugly workaround, must be refactored + resFile, err := contentDir.Open(res.File.Path()) + if err != nil { + http.Error(w, "cannot open: "+err.Error(), http.StatusInternalServerError) + + return + } + defer resFile.Close() + + resBytes, err := io.ReadAll(resFile) + if err != nil { + http.Error(w, "cannot read all: "+err.Error(), http.StatusInternalServerError) + + return + } + defer resFile.Close() + + http.ServeContent(w, r, res.Name(), domain.ResourceModTime(res), bytes.NewReader(resBytes)) + + return + } + + contentLanguage := make([]string, len(p.Translations)) + for i := range p.Translations { + contentLanguage[i] = p.Translations[i].Language.Code() + } + + w.Header().Set(common.HeaderContentLanguage, strings.Join(contentLanguage, ", ")) + + tpl, err := themer.Do(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + + w.Header().Set(common.HeaderContentType, common.MIMETextHTMLCharsetUTF8) + if err = tpl.Execute(w, &Context{ + Site: s, + Page: p, + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + chain := middleware.Chain{middleware.LogFmt()} + server := &http.Server{ + Addr: config.AddrPort().String(), + Handler: chain.Handler(handler), + ErrorLog: logger, + WriteTimeout: 500 * time.Millisecond, + } + + return &App{server: server}, nil +} + +func (a *App) Run(ln net.Listener) error { + var err error + if ln != nil { + err = a.server.Serve(ln) + } else { + err = a.server.ListenAndServe() + } + + if err != nil { + return fmt.Errorf("cannot listen and serve server: %w", err) + } + + return nil +} + +func (a *App) Stop(ctx context.Context) error { + if err := a.server.Shutdown(ctx); err != nil { + return fmt.Errorf("failed to shutdown app: %w", err) + } + + return nil +} diff --git a/main.go b/main.go index b9c6a6f..e324c83 100644 --- a/main.go +++ b/main.go @@ -5,45 +5,23 @@ package main import ( - "bytes" "context" "errors" "flag" - "fmt" - "io" - "io/fs" "log" - "net" "net/http" "os" "os/signal" "path/filepath" "runtime" "runtime/pprof" - "strings" "syscall" - "time" _ "time/tzdata" "github.com/caarlos0/env/v10" - "golang.org/x/text/language" - "source.toby3d.me/toby3d/home/internal/common" + "source.toby3d.me/toby3d/home/internal/cmd/home" "source.toby3d.me/toby3d/home/internal/domain" - "source.toby3d.me/toby3d/home/internal/middleware" - "source.toby3d.me/toby3d/home/internal/page" - pagefsrepo "source.toby3d.me/toby3d/home/internal/page/repository/fs" - pageucase "source.toby3d.me/toby3d/home/internal/page/usecase" - resourcefsrepo "source.toby3d.me/toby3d/home/internal/resource/repository/fs" - resourceucase "source.toby3d.me/toby3d/home/internal/resource/usecase" - sitefsrepo "source.toby3d.me/toby3d/home/internal/site/repository/fs" - siteucase "source.toby3d.me/toby3d/home/internal/site/usecase" - staticfsrepo "source.toby3d.me/toby3d/home/internal/static/repository/fs" - staticucase "source.toby3d.me/toby3d/home/internal/static/usecase" - "source.toby3d.me/toby3d/home/internal/templateutil" - themefsrepo "source.toby3d.me/toby3d/home/internal/theme/repository/fs" - themeucase "source.toby3d.me/toby3d/home/internal/theme/usecase" - "source.toby3d.me/toby3d/home/internal/urlutil" ) type ( @@ -64,185 +42,6 @@ var ( var cpuProfilePath, memProfilePath string -func NewApp(ctx context.Context, config *domain.Config) (*App, error) { - themeDir := os.DirFS(config.ThemeDir) - contentDir := os.DirFS(config.ContentDir) - resources := resourcefsrepo.NewFileServerResourceRepository(contentDir) - sites := sitefsrepo.NewFileSystemSiteRepository(contentDir) - siter := siteucase.NewSiteUseCase(sites, resources) - - funcMap, err := templateutil.New(themeDir, siter) - if err != nil { - logger.Fatalln("cannot setup template.FuncMap for templates: %w", err) - } - - staticDir := os.DirFS(config.StaticDir) - statics := staticfsrepo.NewFileServerStaticRepository(staticDir) - staticer := staticucase.NewStaticUseCase(statics) - resourcer := resourceucase.NewResourceUseCase(resources) - themes := themefsrepo.NewFileSystemThemeRepository(themeDir, funcMap) - themer := themeucase.NewThemeUseCase(themes) - pages := pagefsrepo.NewFileSystemPageRepository(contentDir) - pager := pageucase.NewPageUseCase(pages, resources) - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // INFO(toby3d): any static file is public and unprotected by design, so it's safe to search it - // first before deep down to any page or it's resource which might be secured by middleware or - // something else. - static, err := staticer.Do(r.Context(), strings.TrimPrefix(r.URL.Path, "/")) - if err == nil { - http.ServeContent(w, r, static.Name(), static.ModTime(), static) - - return - } - - lang := domain.LanguageUnd - - s, err := siter.Do(r.Context(), lang) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - - return - } - - if s.IsMultiLingual() { - head, tail := urlutil.ShiftPath(r.URL.Path) - - if head == "" { - supported := make([]language.Tag, len(s.Languages)) - for i := range s.Languages { - supported[i] = language.Make(s.Languages[i].Lang()) - } - - if s.DefaultLanguage != domain.LanguageUnd { - supported = append( - []language.Tag{language.Make(s.DefaultLanguage.Code())}, supported..., - ) - } - - requested, _, err := language.ParseAcceptLanguage( - r.Header.Get(common.HeaderAcceptLanguage)) - if err != nil || len(requested) == 0 { - requested = append(requested, language.English) - } - - matched, _, _ := language.NewMatcher(supported).Match(requested...) - lang = domain.NewLanguage(matched.String()) - - http.Redirect(w, r, "/"+lang.Lang()+"/", http.StatusSeeOther) - - return - } - - lang = domain.NewLanguage(head) - r.URL.Path = tail - } - - if s, err = siter.Do(r.Context(), lang); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - - return - } - - p, err := pager.Do(r.Context(), lang, r.URL.Path) - if err != nil { - if !errors.Is(err, page.ErrNotExist) { - http.Error(w, err.Error(), http.StatusInternalServerError) - - return - } - - res, err := resourcer.Do(r.Context(), r.URL.Path) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - http.Error(w, err.Error(), http.StatusNotFound) - - return - } - - http.Error(w, err.Error(), http.StatusInternalServerError) - - return - } - - // TODO(toby3d) : ugly workaround, must be refactored - resFile, err := contentDir.Open(res.File.Path()) - if err != nil { - http.Error(w, "cannot open: "+err.Error(), http.StatusInternalServerError) - - return - } - defer resFile.Close() - - resBytes, err := io.ReadAll(resFile) - if err != nil { - http.Error(w, "cannot read all: "+err.Error(), http.StatusInternalServerError) - - return - } - defer resFile.Close() - - http.ServeContent(w, r, res.Name(), domain.ResourceModTime(res), bytes.NewReader(resBytes)) - - return - } - - contentLanguage := make([]string, len(p.Translations)) - for i := range p.Translations { - contentLanguage[i] = p.Translations[i].Language.Code() - } - - w.Header().Set(common.HeaderContentLanguage, strings.Join(contentLanguage, ", ")) - - tpl, err := themer.Do(r.Context()) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - - return - } - - w.Header().Set(common.HeaderContentType, common.MIMETextHTMLCharsetUTF8) - if err = tpl.Execute(w, &Context{ - Site: s, - Page: p, - }); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - }) - chain := middleware.Chain{middleware.LogFmt()} - server := &http.Server{ - Addr: config.AddrPort().String(), - Handler: chain.Handler(handler), - ErrorLog: logger, - BaseContext: func(ln net.Listener) context.Context { return ctx }, - WriteTimeout: 500 * time.Millisecond, - } - - return &App{server: server}, nil -} - -func (a *App) Run(ln net.Listener) error { - var err error - if ln != nil { - err = a.server.Serve(ln) - } else { - err = a.server.ListenAndServe() - } - - if err != nil { - return fmt.Errorf("cannot listen and serve server: %w", err) - } - - return nil -} - -func (a *App) Stop(ctx context.Context) error { - if err := a.server.Shutdown(ctx); err != nil { - return fmt.Errorf("failed to shutdown app: %w", err) - } - - return nil -} - func main() { flag.StringVar(&cpuProfilePath, "cpuprofile", "", "set path to saving CPU memory profile") flag.StringVar(&memProfilePath, "memprofile", "", "set path to saving pprof memory profile") @@ -279,7 +78,7 @@ func main() { ctx := context.Background() - app, err := NewApp(ctx, config) + app, err := home.NewApp(logger, config) if err != nil { logger.Fatalln("cannot setup app:", err) }