diff --git a/portal/internal/api/router.go b/portal/internal/api/router.go index 43af312..9bff491 100644 --- a/portal/internal/api/router.go +++ b/portal/internal/api/router.go @@ -2,6 +2,7 @@ package api import ( + "log" "net/http" "github.com/go-chi/chi/v5" @@ -11,6 +12,7 @@ import ( "github.com/omixlab/mosis-portal/internal/auth" "github.com/omixlab/mosis-portal/internal/config" "github.com/omixlab/mosis-portal/internal/database" + "github.com/omixlab/mosis-portal/internal/web" ) // NewRouter creates and configures the HTTP router @@ -122,5 +124,51 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler { r.Post("/review/{versionID}/reject", handlers.NotImplemented) }) + // Web UI routes (htmx + Go templates) + webHandler, err := web.NewHandler(db) + if err != nil { + log.Printf("Warning: Failed to initialize web handler: %v", err) + } else { + sessionMW := web.NewSessionMiddleware(db, cfg.JWTSecret) + + // Public web pages + r.Group(func(r chi.Router) { + r.Use(sessionMW.LoadSession) + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + }) + r.Get("/login", webHandler.Login) + }) + + // Protected web pages + r.Group(func(r chi.Router) { + r.Use(sessionMW.LoadSession) + r.Use(sessionMW.RequireSession) + r.Get("/dashboard", webHandler.Dashboard) + r.Get("/apps/new", webHandler.AppNew) + r.Get("/apps/{appID}", webHandler.AppDetail) + + // htmx partials + r.Get("/partials/apps", webHandler.AppListPartial) + }) + + // Auth callback that sets session (after OAuth) + r.Get("/auth/callback", func(w http.ResponseWriter, r *http.Request) { + developerID := r.URL.Query().Get("developer_id") + if developerID == "" { + http.Redirect(w, r, "/login?error=Authentication failed", http.StatusSeeOther) + return + } + web.SetSession(w, developerID) + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + }) + + // Logout (clears session) + r.Get("/auth/logout", func(w http.ResponseWriter, r *http.Request) { + web.ClearSession(w) + http.Redirect(w, r, "/login", http.StatusSeeOther) + }) + } + return r } diff --git a/portal/internal/web/handlers.go b/portal/internal/web/handlers.go new file mode 100644 index 0000000..b57e13e --- /dev/null +++ b/portal/internal/web/handlers.go @@ -0,0 +1,194 @@ +package web + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/omixlab/mosis-portal/internal/database" +) + +// Handler handles web page requests +type Handler struct { + db *database.DB + templates *Templates +} + +// NewHandler creates a new web handler +func NewHandler(db *database.DB) (*Handler, error) { + templates, err := NewTemplates() + if err != nil { + return nil, err + } + return &Handler{ + db: db, + templates: templates, + }, nil +} + +// PageData is the base data structure for all pages +type PageData struct { + Title string + ActiveNav string + Developer *database.Developer + Error string +} + +// Login renders the login page +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + data := PageData{ + Title: "Sign In", + } + + // Check for error message + if err := r.URL.Query().Get("error"); err != "" { + data.Error = err + } + + h.render(w, "login", data) +} + +// Dashboard renders the dashboard page +func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { + developer := getDeveloperFromContext(r) + if developer == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + // Get stats + apps, total, _ := h.db.ListApps(r.Context(), developer.ID, "", 1, 100) + + data := struct { + PageData + Stats struct { + TotalApps int + Downloads int64 + ActiveUsers int64 + } + Apps []*database.App + }{ + PageData: PageData{ + Title: "Dashboard", + ActiveNav: "dashboard", + Developer: developer, + }, + Apps: apps, + } + data.Stats.TotalApps = total + data.Stats.Downloads = 0 // TODO: implement + data.Stats.ActiveUsers = 0 // TODO: implement + + h.render(w, "dashboard", data) +} + +// AppNew renders the new app form +func (h *Handler) AppNew(w http.ResponseWriter, r *http.Request) { + developer := getDeveloperFromContext(r) + if developer == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + data := PageData{ + Title: "Create New App", + ActiveNav: "apps", + Developer: developer, + } + + h.render(w, "app_new", data) +} + +// AppDetail renders the app detail page +func (h *Handler) AppDetail(w http.ResponseWriter, r *http.Request) { + developer := getDeveloperFromContext(r) + if developer == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + appID := chi.URLParam(r, "appID") + app, err := h.db.GetApp(r.Context(), appID) + if err != nil { + http.Error(w, "App not found", http.StatusNotFound) + return + } + + // Verify ownership + if app.DeveloperID != developer.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + // Get latest version + versions, totalVersions, _ := h.db.ListVersions(r.Context(), appID, "", 1, 1) + var latestVersion *database.AppVersion + if len(versions) > 0 { + latestVersion = versions[0] + } + + data := struct { + PageData + App *database.App + Tab string + LatestVersion *database.AppVersion + TotalVersions int + }{ + PageData: PageData{ + Title: app.Name, + ActiveNav: "apps", + Developer: developer, + }, + App: app, + Tab: "overview", + LatestVersion: latestVersion, + TotalVersions: totalVersions, + } + + h.render(w, "app_detail", data) +} + +// AppListPartial renders the app list partial for htmx +func (h *Handler) AppListPartial(w http.ResponseWriter, r *http.Request) { + developer := getDeveloperFromContext(r) + if developer == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + apps, _, err := h.db.ListApps(r.Context(), developer.ID, "", 1, 100) + if err != nil { + http.Error(w, "Failed to load apps", http.StatusInternalServerError) + return + } + + data := struct { + Apps []*database.App + }{ + Apps: apps, + } + + h.renderPartial(w, "app_list", data) +} + +// render renders a full page template +func (h *Handler) render(w http.ResponseWriter, name string, data interface{}) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.templates.RenderPage(w, name, data); err != nil { + http.Error(w, "Template error: "+err.Error(), http.StatusInternalServerError) + } +} + +// renderPartial renders a partial template +func (h *Handler) renderPartial(w http.ResponseWriter, name string, data interface{}) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.templates.RenderPartial(w, name, data); err != nil { + http.Error(w, "Template error: "+err.Error(), http.StatusInternalServerError) + } +} + +// getDeveloperFromContext retrieves the developer from the request context +// This will be set by the session middleware +func getDeveloperFromContext(r *http.Request) *database.Developer { + developer, _ := r.Context().Value("developer").(*database.Developer) + return developer +} diff --git a/portal/internal/web/session.go b/portal/internal/web/session.go new file mode 100644 index 0000000..0d038b2 --- /dev/null +++ b/portal/internal/web/session.go @@ -0,0 +1,94 @@ +package web + +import ( + "context" + "encoding/gob" + "net/http" + + "github.com/omixlab/mosis-portal/internal/database" +) + +func init() { + // Register types for gob encoding (used by cookie sessions) + gob.Register(&database.Developer{}) +} + +// SessionMiddleware handles session-based authentication for web pages +type SessionMiddleware struct { + db *database.DB + cookieKey string +} + +// NewSessionMiddleware creates a new session middleware +func NewSessionMiddleware(db *database.DB, cookieKey string) *SessionMiddleware { + return &SessionMiddleware{ + db: db, + cookieKey: cookieKey, + } +} + +// LoadSession loads the developer from session cookie +func (m *SessionMiddleware) LoadSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Try to get developer ID from session cookie + cookie, err := r.Cookie("session") + if err != nil { + next.ServeHTTP(w, r) + return + } + + // Lookup developer + developer, err := m.db.GetDeveloper(r.Context(), cookie.Value) + if err != nil { + // Invalid session, clear cookie + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + }) + next.ServeHTTP(w, r) + return + } + + // Add developer to context + ctx := context.WithValue(r.Context(), "developer", developer) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// RequireSession redirects to login if not authenticated +func (m *SessionMiddleware) RequireSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + developer := getDeveloperFromContext(r) + if developer == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + next.ServeHTTP(w, r) + }) +} + +// SetSession creates a session for the developer +func SetSession(w http.ResponseWriter, developerID string) { + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: developerID, + Path: "/", + MaxAge: 60 * 60 * 24 * 30, // 30 days + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) +} + +// ClearSession clears the session cookie +func ClearSession(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + }) +} diff --git a/portal/internal/web/templates.go b/portal/internal/web/templates.go new file mode 100644 index 0000000..62b6aad --- /dev/null +++ b/portal/internal/web/templates.go @@ -0,0 +1,109 @@ +package web + +import ( + "embed" + "html/template" + "io" + "io/fs" + "path/filepath" + "strings" +) + +//go:embed templates/* +var templateFS embed.FS + +// Templates holds the parsed templates +type Templates struct { + templates map[string]*template.Template +} + +// NewTemplates creates and parses all templates +func NewTemplates() (*Templates, error) { + t := &Templates{ + templates: make(map[string]*template.Template), + } + + // Load all layout and partial templates first + layoutFiles, err := fs.Glob(templateFS, "templates/layouts/*.html") + if err != nil { + return nil, err + } + partialFiles, err := fs.Glob(templateFS, "templates/partials/*.html") + if err != nil { + return nil, err + } + + // Load page templates + pageFiles, err := fs.Glob(templateFS, "templates/pages/*.html") + if err != nil { + return nil, err + } + + // Combine layouts and partials + baseFiles := append(layoutFiles, partialFiles...) + + // Parse each page template with layouts and partials + for _, pageFile := range pageFiles { + files := append([]string{pageFile}, baseFiles...) + + // Read and parse all files + tmpl := template.New(filepath.Base(pageFile)) + for _, file := range files { + content, err := templateFS.ReadFile(file) + if err != nil { + return nil, err + } + _, err = tmpl.Parse(string(content)) + if err != nil { + return nil, err + } + } + + // Extract page name (e.g., "login" from "templates/pages/login.html") + name := strings.TrimSuffix(filepath.Base(pageFile), ".html") + t.templates[name] = tmpl + } + + // Also parse partials standalone for htmx partial responses + for _, partialFile := range partialFiles { + content, err := templateFS.ReadFile(partialFile) + if err != nil { + return nil, err + } + tmpl, err := template.New(filepath.Base(partialFile)).Parse(string(content)) + if err != nil { + return nil, err + } + name := "partial:" + strings.TrimSuffix(filepath.Base(partialFile), ".html") + t.templates[name] = tmpl + } + + return t, nil +} + +// RenderPage renders a full page template +func (t *Templates) RenderPage(w io.Writer, name string, data interface{}) error { + tmpl, ok := t.templates[name] + if !ok { + return &ErrTemplateNotFound{Name: name} + } + return tmpl.ExecuteTemplate(w, "base", data) +} + +// RenderPartial renders a partial template (for htmx responses) +func (t *Templates) RenderPartial(w io.Writer, name string, data interface{}) error { + tmpl, ok := t.templates["partial:"+name] + if !ok { + return &ErrTemplateNotFound{Name: name} + } + return tmpl.ExecuteTemplate(w, name, data) +} + +// ErrTemplateNotFound is returned when a template is not found +type ErrTemplateNotFound struct { + Name string +} + +func (e *ErrTemplateNotFound) Error() string { + return "template not found: " + e.Name +} diff --git a/portal/internal/web/templates/layouts/base.html b/portal/internal/web/templates/layouts/base.html new file mode 100644 index 0000000..aa15625 --- /dev/null +++ b/portal/internal/web/templates/layouts/base.html @@ -0,0 +1,33 @@ +{{define "base"}} + + +
+ + +{{.App.PackageID}}
+{{.LatestVersion.VersionName}}
+Version code: {{.LatestVersion.VersionCode}}
+Published
+{{.LatestVersion.PublishedAt.Format "Jan 2, 2006"}}
+ {{else}} + + {{.LatestVersion.Status}} + + {{end}} +No versions uploaded yet.
+ + Upload your first version + + + {{end}} +{{.App.Description}}
+ {{else}} +No description provided.
+ {{end}} +Here's what's happening with your apps.
+Total Apps
+{{.Stats.TotalApps}}
+Total Downloads
+{{.Stats.Downloads}}
+Active Users
+{{.Stats.ActiveUsers}}
+Developer Portal
++ By signing in, you agree to our + Terms of Service + and + Privacy Policy +
++ New to Mosis? + Get started +
+{{.PackageID}}
+