Smail/backend/services/group/group.go
smail f60d3b0880
Some checks are pending
Security Checks / security (push) Waiting to run
整理前后端与测试构建目录并完善配置和安全引导
统一 frontend、backend、test、test-log 和 out 目录,更新构建、部署及测试入口;纳入配置管理兼容与首次安全引导修复;排除本机配置、密钥和测试产物。
2026-09-05 19:19:52 +08:00

782 lines
23 KiB
Go

package group
import (
errors2 "errors"
"fmt"
"git.pcux.cn/xuans/Smail/consts"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/dto"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/utils/array"
"git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/errors"
"git.pcux.cn/xuans/Smail/utils/query"
log "github.com/sirupsen/logrus"
"strings"
"unicode"
"unicode/utf8"
"xorm.io/builder"
"xorm.io/xorm"
)
type GroupItem struct {
Id int `json:"id"`
Label string `json:"label"`
Tag string `json:"tag"`
Children []*GroupItem `json:"children"`
}
const (
maxGroupDepth = 100
maxGroupPathLength = 600
)
func CreateGroup(ctx *context.Context, name string, parentId int) (*models.Group, error) {
name, err := normalizeGroupName(name)
if err != nil {
return nil, err
}
// 先查询是否存在
if parentId < 0 {
return nil, errors2.New("invalid parent group")
}
if parentId == 0 && isReservedGroupName(name) {
return nil, errors2.New("reserved group name")
}
group := models.Group{
Name: name,
ParentId: parentId,
UserId: ctx.UserID,
}
group.FullPath, err = getLayerName(ctx, &group, true)
if err != nil {
return nil, err
}
if err := validateGroupPath(group.FullPath); err != nil {
return nil, err
}
var existing models.Group
found, err := db.Instance.Table("group").Where("full_path = ? and user_id = ?", group.FullPath, ctx.UserID).Get(&existing)
if err != nil {
return nil, err
}
if found && existing.ID > 0 {
return &existing, nil
}
_, err = db.Instance.Insert(&group)
return &group, err
}
func Rename(ctx *context.Context, oldPath, newName string) error {
newName, err := normalizeGroupName(newName)
if err != nil {
return err
}
oldGroupInfo, err := GetGroupByPath(ctx, oldPath)
if err != nil {
return err
}
if oldGroupInfo == nil || oldGroupInfo.ID == 0 {
return errors2.New("group not found")
}
if oldGroupInfo.ParentId == 0 && isReservedGroupName(newName) {
return errors2.New("reserved group name")
}
if oldGroupInfo.Name == newName {
return nil
}
oldGroupInfo.Name = newName
oldGroupInfo.FullPath, err = getLayerName(ctx, oldGroupInfo, true)
if err != nil {
return err
}
if err := validateGroupPath(oldGroupInfo.FullPath); err != nil {
return err
}
session := db.Instance.NewSession()
defer session.Close()
if err := session.Begin(); err != nil {
return errors.Wrap(err)
}
var groups []models.Group
if err := session.Table("group").Where("user_id = ?", ctx.UserID).Find(&groups); err != nil {
rollbackGroupTransaction(session)
return errors.Wrap(err)
}
updates, err := buildRenameUpdates(oldGroupInfo.ID, newName, oldGroupInfo.FullPath, groups)
if err != nil {
rollbackGroupTransaction(session)
return err
}
updatedIDs := make(map[int]struct{}, len(updates))
updatedPaths := make(map[string]int, len(updates))
for _, update := range updates {
if ownerID, exists := updatedPaths[update.FullPath]; exists && ownerID != update.ID {
rollbackGroupTransaction(session)
return errors2.New("group path conflict")
}
updatedIDs[update.ID] = struct{}{}
updatedPaths[update.FullPath] = update.ID
}
for _, group := range groups {
if _, updated := updatedIDs[group.ID]; updated {
continue
}
if _, conflict := updatedPaths[group.FullPath]; conflict {
rollbackGroupTransaction(session)
return errors2.New("group already exists")
}
}
for _, update := range updates {
values := map[string]any{"full_path": update.FullPath}
if update.ID == oldGroupInfo.ID {
values["name"] = newName
}
rows, err := session.Table("group").Where("id = ? and user_id = ?", update.ID, ctx.UserID).Update(values)
if err != nil {
rollbackGroupTransaction(session)
return errors.Wrap(err)
}
if rows != 1 {
rollbackGroupTransaction(session)
return errors2.New("group update failed")
}
}
if err := session.Commit(); err != nil {
return errors.Wrap(err)
}
return nil
}
type groupPathUpdate struct {
ID int
FullPath string
}
type groupPathQueueItem struct {
ID int
Depth int
}
func buildRenameUpdates(rootID int, newName, rootPath string, groups []models.Group) ([]groupPathUpdate, error) {
if rootID <= 0 {
return nil, errors2.New("invalid group")
}
if err := validateGroupPath(rootPath); err != nil {
return nil, err
}
byID := make(map[int]models.Group, len(groups))
children := make(map[int][]models.Group)
for _, group := range groups {
if group.ID <= 0 {
return nil, errors2.New("invalid group hierarchy")
}
if _, exists := byID[group.ID]; exists {
return nil, errors2.New("duplicate group id")
}
byID[group.ID] = group
children[group.ParentId] = append(children[group.ParentId], group)
}
root, exists := byID[rootID]
if !exists {
return nil, errors2.New("group not found")
}
if root.ParentId != 0 {
if _, exists := byID[root.ParentId]; !exists {
return nil, errors2.New("group parent not found")
}
}
paths := map[int]string{rootID: rootPath}
visited := make(map[int]struct{}, len(groups))
queue := []groupPathQueueItem{{ID: rootID, Depth: 1}}
updates := make([]groupPathUpdate, 0)
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
if current.Depth > maxGroupDepth {
return nil, errors2.New("group hierarchy is too deep")
}
if _, exists := visited[current.ID]; exists {
return nil, errors2.New("group hierarchy cycle detected")
}
visited[current.ID] = struct{}{}
updates = append(updates, groupPathUpdate{ID: current.ID, FullPath: paths[current.ID]})
for _, child := range children[current.ID] {
childName, err := normalizeGroupName(child.Name)
if err != nil || childName != child.Name {
return nil, errors2.New("invalid group hierarchy")
}
childPath := paths[current.ID] + "/" + childName
if err := validateGroupPath(childPath); err != nil {
return nil, err
}
paths[child.ID] = childPath
queue = append(queue, groupPathQueueItem{ID: child.ID, Depth: current.Depth + 1})
}
}
if len(updates) == 0 || updates[0].ID != rootID || newName == "" {
return nil, errors2.New("invalid group rename")
}
return updates, nil
}
func GetGroupByName(ctx *context.Context, name string) (*models.Group, error) {
var groups []models.Group
if err := db.Instance.Table("group").Where("name = ? and user_id = ?", name, ctx.UserID).Limit(2).Find(&groups); err != nil {
return nil, err
}
if len(groups) == 0 {
return &models.Group{}, nil
}
if len(groups) > 1 {
return nil, errors2.New("group name is ambiguous")
}
return &groups[0], nil
}
func GetGroupByPath(ctx *context.Context, path string) (*models.Group, error) {
group, err := GetGroupByFullPath(ctx, path)
if err != nil || group.ID > 0 || strings.Contains(path, "/") {
return group, err
}
return GetGroupByName(ctx, path)
}
func GetGroupByFullPath(ctx *context.Context, fullPath string) (*models.Group, error) {
var group models.Group
_, err := db.Instance.Table("group").Where("full_path = ? and user_id = ?", fullPath, ctx.UserID).Get(&group)
return &group, err
}
func DelGroup(ctx *context.Context, groupId int) (bool, error) {
if groupId <= 0 {
return false, errors2.New("invalid group")
}
var root models.Group
found, err := db.Instance.Table("group").Where("id = ? and user_id = ?", groupId, ctx.UserID).Get(&root)
if err != nil {
return false, errors.Wrap(err)
}
if !found {
return false, nil
}
allGroupIds, err := getAllChildIDs(ctx, groupId)
if err != nil {
return false, err
}
allGroupIds = append(allGroupIds, groupId)
placeholders, groupArgs, ok := query.IntList(allGroupIds, 10000)
if !ok {
return false, errors2.New("invalid group list")
}
// 开启一个事务
trans := db.Instance.NewSession()
defer trans.Close()
if err := trans.Begin(); err != nil {
return false, errors.Wrap(err)
}
deleteArgs := append(append([]any{}, groupArgs...), ctx.UserID)
deleteExecArgs := append([]any{db.WithContext(ctx, fmt.Sprintf("delete from `group` where id in (%s) and user_id =?", placeholders))}, deleteArgs...)
res, err := trans.Exec(deleteExecArgs...)
if err != nil {
rollbackGroupTransaction(trans)
return false, errors.Wrap(err)
}
num, err := res.RowsAffected()
if err != nil {
rollbackGroupTransaction(trans)
return false, errors.Wrap(err)
}
updateArgs := append(append([]any{}, groupArgs...), ctx.UserID)
updateExecArgs := append([]any{db.WithContext(ctx, fmt.Sprintf("update user_email set group_id=0 where group_id in (%s) and user_id=?", placeholders))}, updateArgs...)
_, err = trans.Exec(updateExecArgs...)
if err != nil {
rollbackGroupTransaction(trans)
return false, errors.Wrap(err)
}
if err := trans.Commit(); err != nil {
return false, errors.Wrap(err)
}
return num > 0, nil
}
func rollbackGroupTransaction(session *xorm.Session) {
if err := session.Rollback(); err != nil {
log.WithError(err).Error("rollback group transaction failed")
}
}
type id struct {
Id int `db:"id"`
}
func getAllChildIDs(ctx *context.Context, rootID int) ([]int, error) {
visited := map[int]struct{}{rootID: {}}
queue := []int{rootID}
ret := make([]int, 0)
for len(queue) > 0 {
currentID := queue[0]
queue = queue[1:]
var ids []id
if err := db.Instance.Table("group").Where("parent_id=? and user_id=?", currentID, ctx.UserID).Find(&ids); err != nil {
return nil, errors.Wrap(err)
}
for _, item := range ids {
if item.Id <= 0 {
return nil, errors2.New("invalid group hierarchy")
}
if _, exists := visited[item.Id]; exists {
return nil, errors2.New("group hierarchy cycle detected")
}
visited[item.Id] = struct{}{}
ret = append(ret, item.Id)
queue = append(queue, item.Id)
}
}
return ret, nil
}
// GetGroupInfoList 获取全部的分组
func GetGroupInfoList(ctx *context.Context) ([]*GroupItem, error) {
return buildChildren(ctx, 0, make(map[int]struct{}), 0)
}
// MoveMailToGroup 将某封邮件移动到某个分组中
func MoveMailToGroup(ctx *context.Context, mailIDs []int, groupID int) error {
if groupID <= 0 {
return errors2.New("invalid message move request")
}
uniqueIDs, err := normalizeMoveMessageIDs(mailIDs)
if err != nil {
return err
}
session := db.Instance.NewSession()
defer session.Close()
if err := session.Begin(); err != nil {
return fmt.Errorf("begin message move transaction: %w", err)
}
var target models.Group
found, err := session.Table("group").Where("id = ? and user_id = ?", groupID, ctx.UserID).Get(&target)
if err != nil {
rollbackGroupTransaction(session)
return fmt.Errorf("load move destination: %w", err)
}
if !found || target.ID == 0 {
rollbackGroupTransaction(session)
return errors2.New("move destination not found")
}
var relations []models.UserEmail
if err := session.Table(&models.UserEmail{}).Where(builder.Eq{
"email_id": uniqueIDs,
"user_id": ctx.UserID,
}).Find(&relations); err != nil {
rollbackGroupTransaction(session)
return fmt.Errorf("load message ownership: %w", err)
}
if len(relations) != len(uniqueIDs) {
rollbackGroupTransaction(session)
return errors2.New("one or more messages are not owned by the user")
}
expectedChanges := 0
for _, relation := range relations {
if relation.GroupId != groupID {
expectedChanges++
}
}
if expectedChanges > 0 {
affected, err := session.Table(&models.UserEmail{}).Where(builder.Eq{
"email_id": uniqueIDs,
"user_id": ctx.UserID,
}).Cols("group_id").Update(map[string]interface{}{"group_id": groupID})
if err != nil {
rollbackGroupTransaction(session)
return fmt.Errorf("move messages: %w", err)
}
if affected != int64(expectedChanges) {
rollbackGroupTransaction(session)
return fmt.Errorf("move messages affected %d rows, expected %d", affected, expectedChanges)
}
}
if err := session.Commit(); err != nil {
return fmt.Errorf("commit message move transaction: %w", err)
}
return nil
}
func buildChildren(ctx *context.Context, parentId int, visited map[int]struct{}, depth int) ([]*GroupItem, error) {
if depth >= maxGroupDepth {
return nil, errors2.New("group hierarchy is too deep")
}
var ret []*GroupItem
var rootGroup []*models.Group
if err := db.Instance.Table("group").Where("parent_id=? and user_id=?", parentId, ctx.UserID).Find(&rootGroup); err != nil {
return nil, errors.Wrap(err)
}
for _, group := range rootGroup {
if group == nil || group.ID <= 0 {
return nil, errors2.New("invalid group hierarchy")
}
if _, exists := visited[group.ID]; exists {
return nil, errors2.New("group hierarchy cycle detected")
}
visited[group.ID] = struct{}{}
children, err := buildChildren(ctx, group.ID, visited, depth+1)
if err != nil {
return nil, err
}
ret = append(ret, &GroupItem{
Id: group.ID,
Label: group.Name,
Tag: dto.SearchTag{GroupId: group.ID, Status: -1, Type: -1}.ToString(),
Children: children,
})
}
return ret, nil
}
func GetGroupList(ctx *context.Context) ([]*models.Group, error) {
var ret []*models.Group
if err := db.Instance.Table("group").Where("user_id=?", ctx.UserID).Find(&ret); err != nil {
return nil, errors.Wrap(err)
}
return ret, nil
}
func hasChildren(ctx *context.Context, id int) bool {
var parent []*models.Group
if err := db.Instance.Table("group").Where("parent_id=? and user_id=?", id, ctx.UserID).Find(&parent); err != nil {
log.WithContext(ctx).WithError(err).Error("query child groups failed")
return false
}
return len(parent) > 0
}
func getLayerName(ctx *context.Context, item *models.Group, allPath bool) (string, error) {
return getLayerNameWithVisited(ctx, item, allPath, make(map[int]struct{}), 0)
}
func getLayerNameWithVisited(ctx *context.Context, item *models.Group, allPath bool, visited map[int]struct{}, depth int) (string, error) {
if depth >= maxGroupDepth {
return "", errors2.New("group hierarchy is too deep")
}
if item == nil || item.UserId != ctx.UserID {
return "", errors2.New("invalid group")
}
if item.ID > 0 {
if _, exists := visited[item.ID]; exists {
return "", errors2.New("group hierarchy cycle detected")
}
visited[item.ID] = struct{}{}
}
if item.ParentId == 0 {
return item.Name, nil
}
var parent models.Group
found, err := db.Instance.Table("group").Where("id=? and user_id=?", item.ParentId, ctx.UserID).Get(&parent)
if err != nil {
return "", err
}
if !found || parent.ID == 0 {
return "", errors2.New("group parent not found")
}
parentName, err := getLayerNameWithVisited(ctx, &parent, allPath, visited, depth+1)
if err != nil {
return "", err
}
if allPath {
return parentName + "/" + item.Name, nil
}
return parentName, nil
}
func normalizeGroupName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" || strings.Contains(name, "/") || strings.IndexFunc(name, unicode.IsControl) >= 0 {
return "", errors2.New("invalid group name")
}
if utf8.RuneCountInString(name) > 10 {
return "", errors2.New("group name is too long")
}
return name, nil
}
func validateGroupPath(path string) error {
if path == "" || strings.HasPrefix(path, "/") || strings.HasSuffix(path, "/") || strings.Contains(path, "//") {
return errors2.New("invalid group path")
}
if utf8.RuneCountInString(path) > maxGroupPathLength {
return errors2.New("group path is too long")
}
return nil
}
func IsDefaultBox(box string) bool {
return array.InArray(box, []string{"INBOX", "Sent Messages", "Drafts", "Deleted Messages", "Junk"})
}
func isReservedGroupName(name string) bool {
for _, reserved := range []string{"INBOX", "Sent Messages", "Drafts", "Deleted Messages", "Junk"} {
if strings.EqualFold(name, reserved) {
return true
}
}
return false
}
func GetGroupStatus(ctx *context.Context, groupName string, params []string) (string, map[string]int, error) {
retMap := make(map[string]int, len(params))
parts := make([]string, 0, len(params))
if !IsDefaultBox(groupName) {
var group models.Group
found, err := db.Instance.Table("group").Where("user_id=? and full_path=?", ctx.UserID, groupName).Get(&group)
if err != nil {
return "", nil, errors.Wrap(err)
}
if !found || group.ID == 0 {
return "", nil, errors2.New("group not found")
}
for _, param := range params {
var value int
var err error
switch param {
case "MESSAGES":
value, err = readGroupValue(db.Instance.Table("user_email").Select("count(1)").Where("group_id=? and user_id=?", group.ID, ctx.UserID), "query mailbox message count failed")
case "UIDNEXT":
value, err = readGroupValue(db.Instance.Table("user_email").Select("id").Where("group_id=? and user_id=?", group.ID, ctx.UserID).OrderBy("id desc"), "query mailbox next UID failed")
value++
case "UIDVALIDITY":
value = group.ID
case "UNSEEN":
value, err = readGroupValue(db.Instance.Table("user_email").Select("count(1)").Where("group_id=? and user_id=? and is_read=0", group.ID, ctx.UserID), "query mailbox unseen count failed")
default:
continue
}
if err != nil {
return "", nil, err
}
retMap[param] = value
parts = append(parts, fmt.Sprintf("%s %d", param, value))
}
return formatGroupStatus(parts), retMap, nil
}
for _, param := range params {
var value int
var err error
switch param {
case "MESSAGES":
value, err = getGroupNum(ctx, groupName, false)
case "UIDNEXT":
value, err = getNextUID(ctx, groupName)
case "UIDVALIDITY":
value = models.GroupNameToCode[groupName]
case "UNSEEN":
value, err = getGroupNum(ctx, groupName, true)
default:
continue
}
if err != nil {
return "", nil, err
}
retMap[param] = value
parts = append(parts, fmt.Sprintf("%s %d", param, value))
}
return formatGroupStatus(parts), retMap, nil
}
func formatGroupStatus(parts []string) string {
if len(parts) == 0 {
return ""
}
return "(" + strings.Join(parts, " ") + ")"
}
func readGroupValue(querySession *xorm.Session, operation string) (int, error) {
var value int
if _, err := querySession.Get(&value); err != nil {
return 0, fmt.Errorf("%s: %w", operation, err)
}
return value, nil
}
func getNextUID(ctx *context.Context, groupName string) (int, error) {
var querySession *xorm.Session
switch groupName {
case "INBOX":
querySession = db.Instance.Table("user_email").Select("id").Where("user_id=? and group_id=0 and status = 0", ctx.UserID).OrderBy("id desc")
case "Sent Messages":
querySession = db.Instance.Table("user_email").Select("id").Where("user_id=? and group_id=0 and status = 1", ctx.UserID).OrderBy("id desc")
case "Drafts":
querySession = db.Instance.Table("user_email").Select("id").Where("user_id=? and group_id=0 and status = 4", ctx.UserID).OrderBy("id desc")
case "Deleted Messages":
querySession = db.Instance.Table("user_email").Select("id").Where("user_id=? and group_id=0 and status = 3", ctx.UserID).OrderBy("id desc")
case "Junk":
querySession = db.Instance.Table("user_email").Select("id").Where("user_id=? and group_id=0 and status = 5", ctx.UserID).OrderBy("id desc")
default:
return 0, errors2.New("invalid default mailbox")
}
value, err := readGroupValue(querySession, "query next mailbox UID failed")
if err != nil {
return 0, err
}
return value + 1, nil
}
func getGroupNum(ctx *context.Context, groupName string, mustUnread bool) (int, error) {
var querySession *xorm.Session
switch groupName {
case "INBOX":
if mustUnread {
querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=0 and is_read=0", ctx.UserID)
} else {
querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=0", ctx.UserID)
}
case "Sent Messages":
if mustUnread {
return 0, nil
}
querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=1", ctx.UserID)
case "Drafts":
if mustUnread {
querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=4 and is_read=0", ctx.UserID)
} else {
querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=4", ctx.UserID)
}
case "Deleted Messages":
if mustUnread {
querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=3 and is_read=0", ctx.UserID)
} else {
querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=3", ctx.UserID)
}
case "Junk":
if mustUnread {
querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=5 and is_read=0", ctx.UserID)
} else {
querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=5", ctx.UserID)
}
default:
return 0, errors2.New("invalid default mailbox")
}
return readGroupValue(querySession, "query mailbox count failed")
}
func normalizeMoveMessageIDs(mailIDs []int) ([]int, error) {
if len(mailIDs) == 0 {
return nil, errors2.New("empty message ID list")
}
uniqueIDs := make([]int, 0, len(mailIDs))
seen := make(map[int]struct{}, len(mailIDs))
for _, id := range mailIDs {
if id <= 0 {
return nil, errors2.New("invalid message ID")
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
uniqueIDs = append(uniqueIDs, id)
}
if len(uniqueIDs) > 10000 {
return nil, errors2.New("too many message IDs")
}
return uniqueIDs, nil
}
func Move2DefaultBox(ctx *context.Context, mailIDs []int, groupName string) error {
var status int8
switch groupName {
case "INBOX":
status = consts.EmailTypeReceive
case "Sent Messages":
status = consts.EmailStatusSent
case "Drafts":
status = consts.EmailStatusDrafts
case "Deleted Messages":
status = consts.EmailStatusDel
case "Junk":
status = consts.EmailStatusJunk
default:
return errors2.New("invalid default mailbox")
}
uniqueIDs, err := normalizeMoveMessageIDs(mailIDs)
if err != nil {
return err
}
session := db.Instance.NewSession()
defer session.Close()
if err := session.Begin(); err != nil {
return fmt.Errorf("begin default mailbox move transaction: %w", err)
}
var relations []models.UserEmail
if err := session.Table(&models.UserEmail{}).Where(builder.Eq{
"email_id": uniqueIDs,
"user_id": ctx.UserID,
}).Find(&relations); err != nil {
rollbackGroupTransaction(session)
return fmt.Errorf("load default mailbox move ownership: %w", err)
}
if len(relations) != len(uniqueIDs) {
rollbackGroupTransaction(session)
return errors2.New("one or more messages are not owned by the user")
}
expectedChanges := 0
for _, relation := range relations {
if relation.Status != status || relation.GroupId != 0 {
expectedChanges++
}
}
if expectedChanges > 0 {
affected, err := session.Table(&models.UserEmail{}).Where(builder.Eq{
"email_id": uniqueIDs,
"user_id": ctx.UserID,
}).Cols("status", "group_id").Update(map[string]interface{}{
"status": status,
"group_id": 0,
})
if err != nil {
rollbackGroupTransaction(session)
return fmt.Errorf("move messages to default mailbox: %w", err)
}
if affected != int64(expectedChanges) {
rollbackGroupTransaction(session)
return fmt.Errorf("default mailbox move affected %d rows, expected %d", affected, expectedChanges)
}
}
if err := session.Commit(); err != nil {
return fmt.Errorf("commit default mailbox move transaction: %w", err)
}
return nil
}