package handler import ( "fmt" "net/http" "strings" "github.com/labstack/echo/v4" "github.com/labstack/gommon/log" "github.com/ngoduykhanh/wireguard-ui/model" "github.com/ngoduykhanh/wireguard-ui/store" "github.com/ngoduykhanh/wireguard-ui/util" "github.com/rs/xid" ) // MyAccessClientView is the display-friendly shape returned to the // "My Access" self-service portal page - it deliberately omits any secret // key material (private key, preshared key) since non-admins must never // see those in the frontend. type MyAccessClientView struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` ServerID string `json:"server_id"` ServerName string `json:"server_name"` Interface string `json:"interface"` AllocatedIPs []string `json:"allocated_ips"` Enabled bool `json:"enabled"` } // visibleClientsForUser computes the set of clients a given (possibly // non-admin) user is allowed to see on the self-service "My Access" portal: // // (clients whose ServerID is in user.ServerIDs) UNION (clients whose own // ID is in user.ClientIDs) // // Admins bypass the filter entirely and see every client. func visibleClientsForUser(db store.IStore, user model.User, admin bool) ([]model.ClientData, error) { allClients, err := db.GetClients(false) if err != nil { return nil, err } if admin { return allClients, nil } allowedServers := make(map[string]bool, len(user.ServerIDs)) for _, id := range user.ServerIDs { allowedServers[id] = true } allowedClients := make(map[string]bool, len(user.ClientIDs)) for _, id := range user.ClientIDs { allowedClients[id] = true } var visible []model.ClientData for _, cd := range allClients { if allowedServers[cd.Client.ServerID] || allowedClients[cd.Client.ID] { visible = append(visible, cd) } } return visible, nil } // clientAuthorizedForUser reports whether the given user (admin or not) may // access the single client identified by clientData, per the same union // rule used by visibleClientsForUser. func clientAuthorizedForUser(user model.User, admin bool, clientData model.ClientData) bool { if admin { return true } for _, id := range user.ServerIDs { if id == clientData.Client.ServerID { return true } } for _, id := range user.ClientIDs { if id == clientData.Client.ID { return true } } return false } // buildMyAccessViews resolves the server name/interface for each visible // client and shapes them for display, skipping any client whose server // cannot be resolved anymore (orphaned record). func buildMyAccessViews(db store.IStore, clientDataList []model.ClientData) []MyAccessClientView { serverCache := make(map[string]model.Server) views := make([]MyAccessClientView, 0, len(clientDataList)) for _, cd := range clientDataList { serverID := cd.Client.ServerID if serverID == "" { serverID = util.DefaultServerID } server, ok := serverCache[serverID] if !ok { s, err := db.GetServerByID(serverID) if err != nil { continue } server = s serverCache[serverID] = server } interfaceName := "" if server.Interface != nil { interfaceName = server.Interface.Name } views = append(views, MyAccessClientView{ ID: cd.Client.ID, Name: cd.Client.Name, Email: cd.Client.Email, ServerID: serverID, ServerName: server.Name, Interface: interfaceName, AllocatedIPs: cd.Client.AllocatedIPs, Enabled: cd.Client.Enabled, }) } return views } // MyAccessPage renders the read-only, per-user self-service portal: a // non-admin sees only their own assigned clients (via ServerIDs/ClientIDs), // an admin sees every client. View + QR code + download only - no // add/edit/delete controls live on this page. func MyAccessPage(db store.IStore) echo.HandlerFunc { return func(c echo.Context) error { admin := isAdmin(c) user, err := db.GetUserByName(currentUser(c)) if err != nil { return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot load current user"}) } clientDataList, err := visibleClientsForUser(db, user, admin) if err != nil { return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{ false, fmt.Sprintf("Cannot get client list: %v", err), }) } views := buildMyAccessViews(db, clientDataList) return c.Render(http.StatusOK, "my_access.html", map[string]interface{}{ "baseData": model.BaseData{Active: "my-access", CurrentUser: currentUser(c), Admin: admin}, "clients": views, }) } } // GetMyAccessClients returns the same filtered listing as JSON, for the // page's client-side refresh (e.g. after a status change elsewhere). func GetMyAccessClients(db store.IStore) echo.HandlerFunc { return func(c echo.Context) error { admin := isAdmin(c) user, err := db.GetUserByName(currentUser(c)) if err != nil { return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot load current user"}) } clientDataList, err := visibleClientsForUser(db, user, admin) if err != nil { return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{ false, fmt.Sprintf("Cannot get client list: %v", err), }) } return c.JSON(http.StatusOK, buildMyAccessViews(db, clientDataList)) } } // GetMyAccessClientQRCode returns the QR code (as a data-URI, same shape // produced elsewhere in the app) for a single client, provided the // requesting user is authorized to see it per the ServerIDs/ClientIDs union // rule - independent of whether the route-level RequireServerAccess // middleware would have allowed it, since a ClientIDs-only grant may cover a // client on a server the user otherwise has no access to at all. func GetMyAccessClientQRCode(db store.IStore) echo.HandlerFunc { return func(c echo.Context) error { clientID := c.Param("cid") if _, err := xid.FromString(clientID); err != nil { return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"}) } admin := isAdmin(c) user, err := db.GetUserByName(currentUser(c)) if err != nil { return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot load current user"}) } qrCodeSettings := model.QRCodeSettings{ Enabled: true, IncludeDNS: true, IncludeMTU: true, } clientData, err := db.GetClientByID(clientID, qrCodeSettings) if err != nil { return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"}) } if !clientAuthorizedForUser(user, admin, clientData) { return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Access denied"}) } return c.JSON(http.StatusOK, util.FillClientSubnetRange(clientData)) } } // GetMyAccessClientDownload streams the client's WireGuard config file, // same content as the existing DownloadClient handler, but authorized via // the ServerIDs/ClientIDs union rule instead of a server-scoped route // parameter. func GetMyAccessClientDownload(db store.IStore) echo.HandlerFunc { return func(c echo.Context) error { clientID := c.Param("cid") if _, err := xid.FromString(clientID); err != nil { return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"}) } admin := isAdmin(c) user, err := db.GetUserByName(currentUser(c)) if err != nil { return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot load current user"}) } clientData, err := db.GetClientByID(clientID, model.QRCodeSettings{Enabled: false}) if err != nil { log.Errorf("Cannot generate client id %s config file for downloading: %v", clientID, err) return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"}) } if !clientAuthorizedForUser(user, admin, clientData) { return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Access denied"}) } clientServerID := clientData.Client.ServerID if clientServerID == "" { clientServerID = util.DefaultServerID } server, err := db.GetServerByID(clientServerID) if err != nil { return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()}) } globalSettings, err := buildEffectiveSettings(db, clientServerID) if err != nil { return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()}) } config := util.BuildClientConfig(*clientData.Client, server, globalSettings) reader := strings.NewReader(config) c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s.conf", clientData.Client.Name)) return c.Stream(http.StatusOK, "text/conf", reader) } }