storj/satellite/console/consoleweb/consoleapi/projects.go
Jeremy Wharton c8f4f5210d satellite/console: return edge URL overrides in project info responses
API responses containing project information now contain the edge
service URL overrides configured for that project. The overrides are
based on the project's default placement.

References #6188

Change-Id: Ifc3dc74e75c0f5daf0419ac3be184415c65b202e
2023-09-12 12:10:18 -05:00

665 lines
18 KiB
Go

// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleapi
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/zeebo/errs"
"go.uber.org/zap"
"storj.io/common/uuid"
"storj.io/storj/private/web"
"storj.io/storj/satellite/console"
)
// Projects is an api controller that exposes projects related functionality.
type Projects struct {
log *zap.Logger
service *console.Service
}
// ProjectMembersPage contains information about a page of project members and invitations.
type ProjectMembersPage struct {
Members []Member `json:"projectMembers"`
Invitations []Invitation `json:"projectInvitations"`
TotalCount int `json:"totalCount"`
Offset int `json:"offset"`
Limit int `json:"limit"`
Order int `json:"order"`
OrderDirection int `json:"orderDirection"`
Search string `json:"search"`
CurrentPage int `json:"currentPage"`
PageCount int `json:"pageCount"`
}
// Member is a project member in a ProjectMembersPage.
type Member struct {
User *console.User `json:"user"`
JoinedAt time.Time `json:"joinedAt"`
}
// Invitation is a project invitation in a ProjectMembersPage.
type Invitation struct {
Email string `json:"email"`
CreatedAt time.Time `json:"createdAt"`
Expired bool `json:"expired"`
}
// NewProjects is a constructor for api analytics controller.
func NewProjects(log *zap.Logger, service *console.Service) *Projects {
return &Projects{
log: log,
service: service,
}
}
// GetUserProjects returns the user's projects.
func (p *Projects) GetUserProjects(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
w.Header().Set("Content-Type", "application/json")
projects, err := p.service.GetUsersProjects(ctx)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
return
}
response := make([]console.ProjectInfo, 0)
for _, project := range projects {
response = append(response, p.service.GetMinimalProject(&project))
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
}
}
// GetPagedProjects returns paged projects for a user.
func (p *Projects) GetPagedProjects(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
w.Header().Set("Content-Type", "application/json")
query := r.URL.Query()
limitParam := query.Get("limit")
if limitParam == "" {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("parameter 'limit' is required"))
return
}
limit, err := strconv.ParseUint(limitParam, 10, 32)
if err != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, err)
return
}
pageParam := query.Get("page")
if pageParam == "" {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("parameter 'page' is required"))
return
}
page, err := strconv.ParseUint(pageParam, 10, 32)
if err != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, err)
return
}
if page == 0 {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("parameter 'page' can not be 0"))
return
}
cursor := console.ProjectsCursor{
Limit: int(limit),
Page: int(page),
}
projectsPage, err := p.service.GetUsersOwnedProjectsPage(ctx, cursor)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
return
}
pageToSend := console.ProjectInfoPage{
Limit: projectsPage.Limit,
Offset: projectsPage.Offset,
PageCount: projectsPage.PageCount,
CurrentPage: projectsPage.CurrentPage,
TotalCount: projectsPage.TotalCount,
}
for _, project := range projectsPage.Projects {
pageToSend.Projects = append(pageToSend.Projects, p.service.GetMinimalProject(&project))
}
err = json.NewEncoder(w).Encode(pageToSend)
if err != nil {
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
}
}
// UpdateProject handles updating projects.
func (p *Projects) UpdateProject(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
var ok bool
var idParam string
if idParam, ok = mux.Vars(r)["id"]; !ok {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("missing project id route param"))
return
}
id, err := uuid.FromString(idParam)
if err != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, err)
return
}
var payload console.UpsertProjectInfo
err = json.NewDecoder(r.Body).Decode(&payload)
if err != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, err)
return
}
_, err = p.service.UpdateProject(ctx, id, payload)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
}
}
// RequestLimitIncrease handles requesting limit increase for projects.
func (p *Projects) RequestLimitIncrease(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
var ok bool
var idParam string
if idParam, ok = mux.Vars(r)["id"]; !ok {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("missing project id route param"))
return
}
id, err := uuid.FromString(idParam)
if err != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, err)
return
}
var payload console.LimitRequestInfo
err = json.NewDecoder(r.Body).Decode(&payload)
if err != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, err)
return
}
if payload.LimitType == "" {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("missing limit type"))
return
}
if payload.DesiredLimit == 0 {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("missing desired limit"))
return
}
if payload.CurrentLimit == 0 {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("missing current limit"))
return
}
err = p.service.RequestLimitIncrease(ctx, id, payload)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
}
}
// CreateProject handles creating projects.
func (p *Projects) CreateProject(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
var payload console.UpsertProjectInfo
err = json.NewDecoder(r.Body).Decode(&payload)
if err != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, err)
return
}
if payload.Name == "" {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("project name cannot be empty"))
return
}
project, err := p.service.CreateProject(ctx, payload)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
return
}
w.WriteHeader(http.StatusCreated)
err = json.NewEncoder(w).Encode(p.service.GetMinimalProject(project))
if err != nil {
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
}
}
// GetMembersAndInvitations returns the project's members and invitees.
func (p *Projects) GetMembersAndInvitations(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
w.Header().Set("Content-Type", "application/json")
idParam, ok := mux.Vars(r)["id"]
if !ok {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("missing id route param"))
return
}
publicID, err := uuid.FromString(idParam)
if err != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, err)
}
project, err := p.service.GetProject(ctx, publicID)
if err != nil {
if console.ErrUnauthorized.Has(err) || console.ErrNoMembership.Has(err) {
p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
return
}
limitStr := r.URL.Query().Get("limit")
if limitStr == "" {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("missing limit query param"))
return
}
limit, err := strconv.Atoi(limitStr)
if err != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("invalid limit parameter: %s", limitStr))
return
}
search := r.URL.Query().Get("search")
pageStr := r.URL.Query().Get("page")
if pageStr == "" {
pageStr = "1"
}
page, err := strconv.Atoi(pageStr)
if err != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("invalid page parameter: %s", pageStr))
return
}
orderStr := r.URL.Query().Get("order")
if orderStr == "" {
orderStr = "1"
}
order, err := strconv.Atoi(orderStr)
if err != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("invalid order parameter: %s", orderStr))
return
}
orderDirStr := r.URL.Query().Get("order-direction")
if orderDirStr == "" {
orderDirStr = "1"
}
orderDir, err := strconv.Atoi(orderDirStr)
if err != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("invalid order-direction parameter: %s", orderDirStr))
return
}
var memberPage ProjectMembersPage
membersAndInvitations, err := p.service.GetProjectMembersAndInvitations(ctx, project.ID, console.ProjectMembersCursor{
Search: search,
Limit: uint(limit),
Page: uint(page),
Order: console.ProjectMemberOrder(order),
OrderDirection: console.OrderDirection(orderDir),
})
if err != nil {
p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
return
}
memberPage.Search = membersAndInvitations.Search
memberPage.Limit = int(membersAndInvitations.Limit)
memberPage.Order = int(membersAndInvitations.Order)
memberPage.OrderDirection = int(membersAndInvitations.OrderDirection)
memberPage.Offset = int(membersAndInvitations.Offset)
memberPage.PageCount = int(membersAndInvitations.PageCount)
memberPage.CurrentPage = int(membersAndInvitations.CurrentPage)
memberPage.TotalCount = int(membersAndInvitations.TotalCount)
memberPage.Members = []Member{}
memberPage.Invitations = []Invitation{}
for _, m := range membersAndInvitations.ProjectMembers {
user, err := p.service.GetUser(ctx, m.MemberID)
if err != nil {
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
return
}
member := Member{
User: user,
JoinedAt: m.CreatedAt,
}
memberPage.Members = append(memberPage.Members, member)
}
for _, inv := range membersAndInvitations.ProjectInvitations {
invitee := Invitation{
Email: inv.Email,
CreatedAt: inv.CreatedAt,
Expired: p.service.IsProjectInvitationExpired(&inv),
}
memberPage.Invitations = append(memberPage.Invitations, invitee)
}
err = json.NewEncoder(w).Encode(memberPage)
if err != nil {
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
}
}
// GetSalt returns the project's salt.
func (p *Projects) GetSalt(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
w.Header().Set("Content-Type", "application/json")
idParam, ok := mux.Vars(r)["id"]
if !ok {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("missing id route param"))
return
}
id, err := uuid.FromString(idParam)
if err != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, err)
}
salt, err := p.service.GetSalt(ctx, id)
if err != nil {
p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
return
}
b64SaltString := base64.StdEncoding.EncodeToString(salt)
err = json.NewEncoder(w).Encode(b64SaltString)
if err != nil {
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
}
}
// InviteUsers sends invites to a given project(id) to the given users (emails).
func (p *Projects) InviteUsers(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
idParam, ok := mux.Vars(r)["id"]
if !ok {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("missing project id route param"))
return
}
id, err := uuid.FromString(idParam)
if err != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, err)
}
var data struct {
Emails []string `json:"emails"`
}
err = json.NewDecoder(r.Body).Decode(&data)
if err != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, err)
return
}
for i, email := range data.Emails {
data.Emails[i] = strings.TrimSpace(email)
}
_, err = p.service.InviteProjectMembers(ctx, id, data.Emails)
if err != nil {
if console.ErrUnauthorized.Has(err) || console.ErrNoMembership.Has(err) {
p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
}
}
// GetInviteLink returns a link to an invitation given project ID and invitee's email.
func (p *Projects) GetInviteLink(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
idParam, ok := mux.Vars(r)["id"]
if !ok {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("missing project id route param"))
return
}
id, err := uuid.FromString(idParam)
if err != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, err)
}
email := r.URL.Query().Get("email")
if email == "" {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("missing email query param"))
return
}
link, err := p.service.GetInviteLink(ctx, id, email)
if err != nil {
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
}
err = json.NewEncoder(w).Encode(link)
if err != nil {
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
}
}
// GetUserInvitations returns the user's pending project member invitations.
func (p *Projects) GetUserInvitations(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
w.Header().Set("Content-Type", "application/json")
invites, err := p.service.GetUserProjectInvitations(ctx)
if err != nil {
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
return
}
type jsonInvite struct {
ProjectID uuid.UUID `json:"projectID"`
ProjectName string `json:"projectName"`
ProjectDescription string `json:"projectDescription"`
InviterEmail string `json:"inviterEmail"`
CreatedAt time.Time `json:"createdAt"`
}
response := make([]jsonInvite, 0)
for _, invite := range invites {
proj, err := p.service.GetProjectNoAuth(ctx, invite.ProjectID)
if err != nil {
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
return
}
respInvite := jsonInvite{
ProjectID: proj.PublicID,
ProjectName: proj.Name,
ProjectDescription: proj.Description,
CreatedAt: invite.CreatedAt,
}
if invite.InviterID != nil {
inviter, err := p.service.GetUser(ctx, *invite.InviterID)
if err != nil {
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
return
}
respInvite.InviterEmail = inviter.Email
}
response = append(response, respInvite)
}
err = json.NewEncoder(w).Encode(response)
if err != nil {
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
}
}
// RespondToInvitation handles accepting or declining a user's project member invitation.
func (p *Projects) RespondToInvitation(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
var ok bool
var idParam string
if idParam, ok = mux.Vars(r)["id"]; !ok {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("missing project id route param"))
return
}
id, err := uuid.FromString(idParam)
if err != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, err)
return
}
var payload struct {
Response console.ProjectInvitationResponse `json:"response"`
}
err = json.NewDecoder(r.Body).Decode(&payload)
if err != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, err)
return
}
err = p.service.RespondToProjectInvitation(ctx, id, payload.Response)
if err != nil {
status := http.StatusInternalServerError
switch {
case console.ErrAlreadyMember.Has(err):
status = http.StatusConflict
case console.ErrProjectInviteInvalid.Has(err):
status = http.StatusNotFound
case console.ErrValidation.Has(err):
status = http.StatusBadRequest
}
p.serveJSONError(ctx, w, status, err)
}
}
// DeleteMembersAndInvitations deletes members and invitations from a project.
func (p *Projects) DeleteMembersAndInvitations(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
var ok bool
var idParam string
if idParam, ok = mux.Vars(r)["id"]; !ok {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("missing project id route param"))
return
}
id, err := uuid.FromString(idParam)
if err != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, err)
}
emailsStr := r.URL.Query().Get("emails")
if emailsStr == "" {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("missing emails parameter"))
return
}
emails := strings.Split(emailsStr, ",")
err = p.service.DeleteProjectMembersAndInvitations(ctx, id, emails)
if err != nil {
if console.ErrUnauthorized.Has(err) || console.ErrNoMembership.Has(err) {
p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
}
}
// serveJSONError writes JSON error to response output stream.
func (p *Projects) serveJSONError(ctx context.Context, w http.ResponseWriter, status int, err error) {
web.ServeJSONError(ctx, p.log, w, status, err)
}