diff --git a/go.mod b/go.mod index 55e653da..d133d61b 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/h2non/filetype v1.1.3 github.com/json-iterator/go v1.1.12 github.com/prometheus/client_golang v1.12.2 - github.com/seventv/common v0.0.0-20220723183359-3cfda790f9b8 + github.com/seventv/common v0.0.0-20220725204002-4d8447b9673c github.com/seventv/compactdisc v0.0.0-20220723184527-d3d767eadb5c github.com/seventv/image-processor/go v0.0.0-20220717125033-bf54c62116c2 github.com/seventv/message-queue/go v0.0.0-20220623223012-800919900c0d diff --git a/go.sum b/go.sum index 1fa785c7..4251ba10 100644 --- a/go.sum +++ b/go.sum @@ -423,12 +423,12 @@ github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d h1:Q+gqLBOPkFGHyCJx github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/seventv/common v0.0.0-20220723113936-4f27199129f8 h1:lpB0AszlSI2IOc9igG5CvQMf/akVPembBbg0gsO0nQI= -github.com/seventv/common v0.0.0-20220723113936-4f27199129f8/go.mod h1:++S6KoMQtZ4OIZmcDbN26W2/I6JrNP/T/5a+/oLSpM8= github.com/seventv/common v0.0.0-20220723183359-3cfda790f9b8 h1:qMgsscKzrbPc7mqxMOLNmc2Ass28XqZuS6fcGZY8IrM= github.com/seventv/common v0.0.0-20220723183359-3cfda790f9b8/go.mod h1:++S6KoMQtZ4OIZmcDbN26W2/I6JrNP/T/5a+/oLSpM8= -github.com/seventv/compactdisc v0.0.0-20220723124103-54cf35b6d79a h1:bYki5ep7+Wv6gmlhyVWvuezs9DskiN3Ivsxa2UbzPoU= -github.com/seventv/compactdisc v0.0.0-20220723124103-54cf35b6d79a/go.mod h1:9jcgyl29MWEtYkOrZLF9Q2gcfuP1eMb8bCvCQRteRkQ= +github.com/seventv/common v0.0.0-20220725144048-1b12f1900e7f h1:wxq4bHVLef253HFQqGFbzjsx+zffxFqnDzD8rRQZhqk= +github.com/seventv/common v0.0.0-20220725144048-1b12f1900e7f/go.mod h1:++S6KoMQtZ4OIZmcDbN26W2/I6JrNP/T/5a+/oLSpM8= +github.com/seventv/common v0.0.0-20220725204002-4d8447b9673c h1:RGigolwlaDaoJXoDEg3E72DX+UG0qxx04lNORbBHRWI= +github.com/seventv/common v0.0.0-20220725204002-4d8447b9673c/go.mod h1:++S6KoMQtZ4OIZmcDbN26W2/I6JrNP/T/5a+/oLSpM8= github.com/seventv/compactdisc v0.0.0-20220723184527-d3d767eadb5c h1:8L3Dr12VNg03iIlFYsEoo4/kQJOcsEf2sjdnj8N3mus= github.com/seventv/compactdisc v0.0.0-20220723184527-d3d767eadb5c/go.mod h1:f9JVdhYnBwWk8Rn2w0lL0rWXxZAkWuYBAdvMq1f+eno= github.com/seventv/image-processor/go v0.0.0-20220717125033-bf54c62116c2 h1:yRuIABJ6SQCLYmRSyP20Vq/J9ak3lDxhPE6gYXBh2As= diff --git a/internal/gql/v2/helpers/transform.go b/internal/gql/v2/helpers/transform.go index a0b91492..cae0077c 100644 --- a/internal/gql/v2/helpers/transform.go +++ b/internal/gql/v2/helpers/transform.go @@ -195,7 +195,7 @@ func RoleStructureToModel(s structures.Role) *model.Role { structures.RolePermissionCreateEmote: v2structures.RolePermissionEmoteCreate, structures.RolePermissionEditEmote: v2structures.RolePermissionEmoteEditOwned, structures.RolePermissionEditAnyEmote: v2structures.RolePermissionEmoteEditAll, - structures.RolePermissionReportCreate: v2structures.RolePermissionCreateReports, + structures.RolePermissionCreateReport: v2structures.RolePermissionCreateReports, structures.RolePermissionManageBans: v2structures.RolePermissionBanUsers, structures.RolePermissionManageUsers: v2structures.RolePermissionManageUsers, structures.RolePermissionManageStack: v2structures.RolePermissionEditApplicationMeta, diff --git a/internal/gql/v3/helpers/transform.go b/internal/gql/v3/helpers/transform.go index aa49c799..b28f6b40 100644 --- a/internal/gql/v3/helpers/transform.go +++ b/internal/gql/v3/helpers/transform.go @@ -182,6 +182,10 @@ func EmoteStructureToModel(s structures.Emote, cdnURL string) *model.Emote { } files := ver.GetFiles("", true) + sort.Slice(files, func(i, j int) bool { + return files[i].Width < files[j].Width + }) + vimages := make([]*model.Image, len(files)) for i, fi := range files { @@ -376,6 +380,27 @@ func MessageStructureToModRequestModel(s structures.Message[structures.MessageDa } } +func ReportStructureToModel(s structures.Report) *model.Report { + assignees := make([]*model.User, len(s.AssigneeIDs)) + for i, oid := range s.AssigneeIDs { + assignees[i] = &model.User{ID: oid} + } + + return &model.Report{ + ID: s.ID, + TargetKind: int(s.TargetKind), + TargetID: s.TargetID, + ActorID: s.ActorID, + Subject: s.Subject, + Body: s.Body, + Priority: int(s.Priority), + Status: model.ReportStatus(s.Status), + CreatedAt: s.CreatedAt, + Notes: []string{}, + Assignees: assignees, + } +} + func BanStructureToModel(s structures.Ban) *model.Ban { return &model.Ban{ ID: s.ID, diff --git a/internal/gql/v3/middleware/has-permission.go b/internal/gql/v3/middleware/has-permission.go index 70f3905b..6ea7babf 100644 --- a/internal/gql/v3/middleware/has-permission.go +++ b/internal/gql/v3/middleware/has-permission.go @@ -53,7 +53,7 @@ func hasPermission(gCtx global.Context) func(ctx context.Context, obj interface{ case model.PermissionManageUsers: perms |= structures.RolePermissionManageUsers case model.PermissionCreateReport: - perms |= structures.RolePermissionReportCreate + perms |= structures.RolePermissionCreateReport case model.PermissionSendMessages: perms |= structures.RolePermissionSendMessages case model.PermissionSuperAdministrator: diff --git a/internal/gql/v3/resolvers/mutation/mutation.go b/internal/gql/v3/resolvers/mutation/mutation.go index 4433db7e..7918c3a0 100644 --- a/internal/gql/v3/resolvers/mutation/mutation.go +++ b/internal/gql/v3/resolvers/mutation/mutation.go @@ -37,16 +37,6 @@ func (r *Resolver) DeleteRole(ctx context.Context, roleID primitive.ObjectID) (s return "", nil } -func (r *Resolver) CreateReport(ctx context.Context, data model.CreateReportInput) (*model.Report, error) { - // TODO - return nil, nil -} - -func (r *Resolver) EditReport(ctx context.Context, reportID primitive.ObjectID, data model.EditReportInput) (*model.Report, error) { - // primitive.ObjectID - return nil, nil -} - // Cosmetics implements generated.MutationResolver func (*Resolver) Cosmetics(ctx context.Context, id primitive.ObjectID) (*model.CosmeticOps, error) { return &model.CosmeticOps{ diff --git a/internal/gql/v3/resolvers/mutation/mutation.reports.go b/internal/gql/v3/resolvers/mutation/mutation.reports.go new file mode 100644 index 00000000..318144fd --- /dev/null +++ b/internal/gql/v3/resolvers/mutation/mutation.reports.go @@ -0,0 +1,250 @@ +package mutation + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/99designs/gqlgen/graphql" + "github.com/seventv/api/internal/gql/v3/auth" + "github.com/seventv/api/internal/gql/v3/gen/model" + "github.com/seventv/api/internal/gql/v3/helpers" + "github.com/seventv/common/errors" + "github.com/seventv/common/mongo" + "github.com/seventv/common/structures/v3" + "github.com/seventv/common/structures/v3/mutations" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" +) + +const ( + REPORT_SUBJECT_MIN_LENGTH = 4 + REPORT_SUBJECT_MAX_LENGTH = 72 + REPORT_BODY_MIN_LENGTH = 4 + REPORT_BODY_MAX_LENGTH = 2000 + REPORT_ALLOWED_ACTIVE_PER_USER = 3 +) + +func (r *Resolver) CreateReport(ctx context.Context, data model.CreateReportInput) (*model.Report, error) { + actor := auth.For(ctx) + if actor.ID.IsZero() { + return nil, errors.ErrUnauthorized() + } + + // Get and verify the target + var ( + errType error + kind = structures.ObjectKind(data.TargetKind) + targetFilter bson.M + ) + + switch structures.ObjectKind(data.TargetKind) { + case structures.ObjectKindUser: + errType = errors.ErrUnknownUser() + targetFilter = bson.M{"_id": data.TargetID} + case structures.ObjectKindEmote: + errType = errors.ErrUnknownEmote() + targetFilter = bson.M{"versions.id": data.TargetID} + default: + return nil, errors.ErrEmoteNameInvalid().SetDetail("You cannot report type %s", kind.String()) + } + + if c, _ := r.Ctx.Inst().Mongo.Collection(mongo.CollectionName(kind.CollectionName())).CountDocuments(ctx, targetFilter); c == 0 { + return nil, errType + } + + // Validate the input + if len(data.Subject) < REPORT_SUBJECT_MIN_LENGTH { + graphql.AddError(ctx, errors.ErrInvalidRequest().SetDetail(fmt.Sprintf("subject must be at least %d characters long", REPORT_SUBJECT_MIN_LENGTH))) + } + + if len(data.Subject) > REPORT_SUBJECT_MAX_LENGTH { + graphql.AddError(ctx, errors.ErrInvalidRequest().SetDetail(fmt.Sprintf("subject must be at most %d characters long", REPORT_SUBJECT_MAX_LENGTH))) + } + + if len(data.Body) < REPORT_BODY_MIN_LENGTH { + graphql.AddError(ctx, errors.ErrInvalidRequest().SetDetail(fmt.Sprintf("body must be at least %d characters long", REPORT_BODY_MIN_LENGTH))) + } + + if len(data.Body) > REPORT_BODY_MAX_LENGTH { + graphql.AddError(ctx, errors.ErrInvalidRequest().SetDetail(fmt.Sprintf("body must be at most %d characters long", REPORT_BODY_MAX_LENGTH))) + } + + if len(graphql.GetErrors(ctx)) > 0 { + return nil, errors.ErrValidationRejected().SetDetail("Some fields have been filled incorrectly") + } + + // Create the report + t := time.Now() + + l := kind.String()[:1] + yr := strconv.Itoa(t.Year()) + mo := strconv.Itoa(int(t.Month())) + dy := strconv.Itoa(t.Day()) + sc := int(time.Since(time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)).Seconds()) + + caseID := fmt.Sprintf("%s-%s%s%s%d", l, yr[len(yr)-2:], mo, dy, sc) + + rb := structures.NewReportBuilder(structures.Report{ + CaseID: caseID, + AssigneeIDs: []primitive.ObjectID{}, + }) + rb.Report.ID = primitive.NewObjectIDFromTimestamp(time.Now()) + rb.SetTargetKind(kind). + SetTargetID(data.TargetID). + SetReporterID(actor.ID). + SetStatus(structures.ReportStatusOpen). + SetSubject(data.Subject). + SetBody(data.Body). + SetCreatedAt(t) + + _, err := r.Ctx.Inst().Mongo.Collection(mongo.CollectionNameReports).InsertOne(ctx, rb.Report) + if err != nil { + zap.S().Errorw("mongo", "error", err) + + return nil, errors.ErrInternalServerError().SetDetail("Report creation could not be completed") + } + + // Create AuditLog + truncBody := data.Subject + if len(truncBody) > 128 { + truncBody = truncBody[:128] + "..." + } + + alb := structures.NewAuditLogBuilder(structures.AuditLog{ + Reason: data.Subject + ": " + truncBody, + }). + SetActor(actor.ID). + SetKind(structures.AuditLogKindCreateReport). + SetTargetKind(structures.ObjectKindReport). + SetTargetID(rb.Report.ID) + + if _, err := r.Ctx.Inst().Mongo.Collection(mongo.CollectionNameAuditLogs).InsertOne(ctx, alb.AuditLog); err != nil { + zap.S().Errorw("mongo, failed to write audit log", "error", err) + } + + return &model.Report{}, nil +} + +func (r *Resolver) EditReport(ctx context.Context, reportID primitive.ObjectID, data model.EditReportInput) (*model.Report, error) { + actor := auth.For(ctx) + if actor.ID.IsZero() { + return nil, errors.ErrUnauthorized() + } + + // Get the report + report := structures.Report{} + if err := r.Ctx.Inst().Mongo.Collection(mongo.CollectionNameReports).FindOne(ctx, bson.M{"_id": reportID}).Decode(&report); err != nil { + if err == mongo.ErrNoDocuments { + return nil, errors.ErrUnknownReport() + } + + return nil, errors.ErrInternalServerError() + } + + // Apply mutations + rb := structures.NewReportBuilder(report) + + alb := structures.NewAuditLogBuilder(structures.AuditLog{}). + SetKind(structures.AuditLogKindCreateReport). + SetActor(actor.ID). + SetTargetKind(structures.ObjectKindReport). + SetTargetID(report.ID) + + if data.Priority != nil { + p := *data.Priority + + alb = alb.AddChanges((&structures.AuditLogChange{ + Format: structures.AuditLogChangeFormatSingleValue, + Key: "priority", + }).WriteSingleValues(report.Priority, p)) + + rb.SetPriority(int32(p)) + } + + if data.Status != nil { + st := *data.Status + + alb = alb.AddChanges((&structures.AuditLogChange{ + Format: structures.AuditLogChangeFormatSingleValue, + Key: "status", + }).WriteSingleValues(report.Status, structures.ReportStatus(st))) + + rb.SetStatus(structures.ReportStatus(st)) + + if st == model.ReportStatusClosed { + rb.SetClosedAt(time.Now()) + + // Send notification to the user that their report has been handled + mb := structures.NewMessageBuilder(structures.Message[structures.MessageDataInbox]{}). + SetKind(structures.MessageKindInbox). + SetAuthorID(actor.ID). + SetTimestamp(time.Now()). + SetAnonymous(false). + SetData(structures.MessageDataInbox{ + Subject: "inbox.generic.report_closed.subject", + Content: "inbox.generic.report_closed.content", + Locale: true, + System: true, + Placeholders: map[string]string{ + "CASE_ID": report.CaseID, + }, + }) + + _ = r.Ctx.Inst().Mutate.SendInboxMessage(ctx, mb, mutations.SendInboxMessageOptions{ + Actor: &actor, + Recipients: []primitive.ObjectID{report.ActorID}, + ConsiderBlockedUsers: false, + }) + } else { + rb.SetClosedAt(time.Time{}) + } + } + + if data.Assignee != nil { + a := *data.Assignee + + c := &structures.AuditLogChange{ + Format: structures.AuditLogChangeFormatArrayChange, + Key: "assignee_ids", + } + + assigneeID, err := primitive.ObjectIDFromHex(a[1:]) + + if err != nil { + return nil, errors.ErrBadObjectID() + } + + state := a[0] + switch state { + case '+': + rb.AddAssignee(assigneeID) + c.WriteArrayAdded(assigneeID) + case '-': + rb.RemoveAssignee(assigneeID) + c.WriteArrayRemoved(assigneeID) + default: + return nil, errors.ErrInvalidRequest().SetDetail("assignee must be prefixed with '+' or '-'") + } + + alb = alb.AddChanges(c) + } + + rb.SetLastUpdatedAt(time.Now()) + + // Write update + if _, err := r.Ctx.Inst().Mongo.Collection(mongo.CollectionNameReports).UpdateOne(ctx, bson.M{ + "_id": reportID, + }, rb.Update); err != nil { + return nil, errors.ErrInternalServerError() + } + + // Write audit log + if _, err := r.Ctx.Inst().Mongo.Collection(mongo.CollectionNameAuditLogs).InsertOne(ctx, alb.AuditLog); err != nil { + zap.S().Errorw("mongo, failed to write audit log", "error", err) + } + + return helpers.ReportStructureToModel(rb.Report), nil +} diff --git a/internal/gql/v3/resolvers/query/query.go b/internal/gql/v3/resolvers/query/query.go index faf293e0..8572b2ee 100644 --- a/internal/gql/v3/resolvers/query/query.go +++ b/internal/gql/v3/resolvers/query/query.go @@ -82,16 +82,6 @@ func (r *Resolver) Role(ctx context.Context, id primitive.ObjectID) (*model.Role return nil, nil } -func (r *Resolver) Reports(ctx context.Context, status *model.ReportStatus, limit *int, afterID *string, beforeID *string) ([]*model.Report, error) { - // TODO - return nil, nil -} - -func (r *Resolver) Report(ctx context.Context, id primitive.ObjectID) (*model.Report, error) { - // TODO - return nil, nil -} - type Sort struct { Value string `json:"value"` Order SortOrder `json:"order"` diff --git a/internal/gql/v3/resolvers/query/query.reports.go b/internal/gql/v3/resolvers/query/query.reports.go new file mode 100644 index 00000000..7b289c33 --- /dev/null +++ b/internal/gql/v3/resolvers/query/query.reports.go @@ -0,0 +1,89 @@ +package query + +import ( + "context" + + "github.com/seventv/api/internal/gql/v3/auth" + "github.com/seventv/api/internal/gql/v3/gen/model" + "github.com/seventv/api/internal/gql/v3/helpers" + "github.com/seventv/common/errors" + "github.com/seventv/common/mongo" + "github.com/seventv/common/structures/v3" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" + "go.uber.org/zap" +) + +func (r *Resolver) Reports(ctx context.Context, statusArg *model.ReportStatus, limitArg *int, afterIDArg *primitive.ObjectID, beforeIDArg *primitive.ObjectID) ([]*model.Report, error) { + actor := auth.For(ctx) + if actor.ID.IsZero() { + return nil, errors.ErrUnauthorized() + } + + // Define limit + limit := int64(12) + if limitArg != nil { + limit = int64(*limitArg) + } + + if limit > 100 { + limit = 100 + } + + // Paginate + pagination := bson.M{} + filter := bson.M{} + + if statusArg != nil { + filter["status"] = *statusArg + } + + if afterIDArg != nil { + pagination["$gt"] = *afterIDArg + } + + if beforeIDArg != nil { + pagination["$lt"] = *beforeIDArg + } + + if len(pagination) > 0 { + filter["_id"] = pagination + } + + opt := options.Find().SetLimit(limit).SetSort(bson.M{"created_at": 1}) + + cur, err := r.Ctx.Inst().Mongo.Collection(mongo.CollectionNameReports).Find(ctx, filter, opt) + if err != nil { + zap.S().Errorw("mongo, failed to create reports query", "error", err) + + return nil, errors.ErrInternalServerError() + } + + reports := []structures.Report{} + if err := cur.All(ctx, &reports); err != nil { + zap.S().Errorw("mongo, failed to query reports") + + return nil, errors.ErrInternalServerError() + } + + result := make([]*model.Report, len(reports)) + for i, report := range reports { + result[i] = helpers.ReportStructureToModel(report) + } + + return result, nil +} + +func (r *Resolver) Report(ctx context.Context, id primitive.ObjectID) (*model.Report, error) { + report := structures.Report{} + if err := r.Ctx.Inst().Mongo.Collection(mongo.CollectionNameReports).FindOne(ctx, bson.M{"_id": id}).Decode(&report); err != nil { + if err == mongo.ErrNoDocuments { + return nil, errors.ErrUnknownReport() + } + + return nil, errors.ErrInternalServerError() + } + + return helpers.ReportStructureToModel(report), nil +} diff --git a/internal/gql/v3/resolvers/report/report.go b/internal/gql/v3/resolvers/report/report.go index b05b249f..cf74ff95 100644 --- a/internal/gql/v3/resolvers/report/report.go +++ b/internal/gql/v3/resolvers/report/report.go @@ -15,12 +15,22 @@ type Resolver struct { types.Resolver } +// Actor implements generated.ReportResolver +func (r *Resolver) Actor(ctx context.Context, obj *model.Report) (*model.User, error) { + user, err := r.Ctx.Inst().Loaders.UserByID().Load(obj.ActorID) + if err != nil { + return nil, err + } + + return helpers.UserStructureToModel(user, r.Ctx.Config().CdnURL), nil +} + func New(r types.Resolver) generated.ReportResolver { return &Resolver{r} } func (r *Resolver) Reporter(ctx context.Context, obj *model.Report) (*model.User, error) { - user, err := r.Ctx.Inst().Loaders.UserByID().Load(obj.Reporter.ID) + user, err := r.Ctx.Inst().Loaders.UserByID().Load(obj.Actor.ID) if err != nil { return nil, err } diff --git a/internal/gql/v3/schema/reports.gql b/internal/gql/v3/schema/reports.gql index 2a071ffc..f0c910b3 100644 --- a/internal/gql/v3/schema/reports.gql +++ b/internal/gql/v3/schema/reports.gql @@ -2,8 +2,8 @@ extend type Query { reports( status: ReportStatus limit: Int - after_id: String - before_id: String + after_id: ObjectID + before_id: ObjectID ): [Report]! @hasPermissions(role: [MANAGE_REPORTS]) report(id: ObjectID!): Report @hasPermissions(role: [MANAGE_REPORTS]) } @@ -17,24 +17,19 @@ extend type Mutation { type Report { id: ObjectID! - target_kind: TargetKind! + target_kind: Int! target_id: ObjectID! + actor_id: ObjectID! + actor: User! @goField(forceResolver: true) subject: String! body: String! priority: Int! status: ReportStatus! created_at: Time! notes: [String!]! - - reporter: User! @goField(forceResolver: true) assignees: [User!]! @goField(forceResolver: true) } -enum TargetKind { - EMOTE - USER -} - enum ReportStatus { OPEN ASSIGNED @@ -42,8 +37,8 @@ enum ReportStatus { } input CreateReportInput { - target_kind: TargetKind! - target_id: String! + target_kind: Int! + target_id: ObjectID! subject: String! body: String! } diff --git a/internal/rest/v2/model/role.go b/internal/rest/v2/model/role.go index 9e6c2ba8..8a98d0e8 100644 --- a/internal/rest/v2/model/role.go +++ b/internal/rest/v2/model/role.go @@ -24,7 +24,7 @@ func NewRole(s structures.Role) *Role { p |= v2structures.RolePermissionEmoteEditOwned case structures.RolePermissionEditAnyEmote: p |= v2structures.RolePermissionEmoteEditAll - case structures.RolePermissionReportCreate: + case structures.RolePermissionCreateReport: p |= v2structures.RolePermissionCreateReports case structures.RolePermissionManageBans: p |= v2structures.RolePermissionBanUsers