From d326c710789197b225a065c79468e6c4470afbd0 Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 6 Jan 2026 23:36:42 +0800 Subject: [PATCH 01/86] =?UTF-8?q?refactor(db):=20rename=20tables=20for=20c?= =?UTF-8?q?larity=20-=20resource=E2=86=92attachment,=20system=5Fsetting?= =?UTF-8?q?=E2=86=92instance=5Fsetting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugin/email/email_test.go | 26 +++------ plugin/filter/schema.go | 8 +-- store/db/mysql/attachment.go | 54 +++++++++--------- store/db/mysql/instance_setting.go | 6 +- store/db/postgres/attachment.go | 56 +++++++++---------- store/db/postgres/instance_setting.go | 6 +- store/db/sqlite/attachment.go | 56 +++++++++---------- store/db/sqlite/instance_setting.go | 6 +- .../00__rename_resource_to_attachment.sql | 1 + ...ame_system_setting_to_instance_setting.sql | 1 + store/migration/mysql/LATEST.sql | 8 +-- .../00__rename_resource_to_attachment.sql | 1 + ...ame_system_setting_to_instance_setting.sql | 1 + store/migration/postgres/LATEST.sql | 8 +-- .../00__rename_resource_to_attachment.sql | 5 ++ ...ame_system_setting_to_instance_setting.sql | 1 + store/migration/sqlite/LATEST.sql | 12 ++-- store/seed/sqlite/00__reset.sql | 11 ---- 18 files changed, 127 insertions(+), 140 deletions(-) create mode 100644 store/migration/mysql/0.26/00__rename_resource_to_attachment.sql create mode 100644 store/migration/mysql/0.26/01__rename_system_setting_to_instance_setting.sql create mode 100644 store/migration/postgres/0.26/00__rename_resource_to_attachment.sql create mode 100644 store/migration/postgres/0.26/01__rename_system_setting_to_instance_setting.sql create mode 100644 store/migration/sqlite/0.26/00__rename_resource_to_attachment.sql create mode 100644 store/migration/sqlite/0.26/01__rename_system_setting_to_instance_setting.sql delete mode 100644 store/seed/sqlite/00__reset.sql diff --git a/plugin/email/email_test.go b/plugin/email/email_test.go index 7927512ec..f3eebee09 100644 --- a/plugin/email/email_test.go +++ b/plugin/email/email_test.go @@ -1,11 +1,11 @@ package email import ( - "sync" "testing" "time" "github.com/stretchr/testify/assert" + "golang.org/x/sync/errgroup" ) func TestSend(t *testing.T) { @@ -106,34 +106,22 @@ func TestSendAsyncConcurrent(t *testing.T) { FromEmail: "test@example.com", } - // Send multiple emails concurrently - var wg sync.WaitGroup + g := errgroup.Group{} count := 5 for i := 0; i < count; i++ { - wg.Add(1) - go func() { - defer wg.Done() + g.Go(func() error { message := &Message{ To: []string{"recipient@example.com"}, Subject: "Concurrent Test", Body: "Test body", } SendAsync(config, message) - }() + return nil + }) } - // Should complete without deadlock - done := make(chan bool) - go func() { - wg.Wait() - done <- true - }() - - select { - case <-done: - // Success - case <-time.After(1 * time.Second): - t.Fatal("SendAsync calls did not complete in time") + if err := g.Wait(); err != nil { + t.Fatalf("SendAsync calls failed: %v", err) } } diff --git a/plugin/filter/schema.go b/plugin/filter/schema.go index c172eb62a..f2f8b0e4a 100644 --- a/plugin/filter/schema.go +++ b/plugin/filter/schema.go @@ -256,7 +256,7 @@ func NewAttachmentSchema() Schema { Name: "filename", Kind: FieldKindScalar, Type: FieldTypeString, - Column: Column{Table: "resource", Name: "filename"}, + Column: Column{Table: "attachment", Name: "filename"}, SupportsContains: true, Expressions: map[DialectName]string{}, }, @@ -264,14 +264,14 @@ func NewAttachmentSchema() Schema { Name: "mime_type", Kind: FieldKindScalar, Type: FieldTypeString, - Column: Column{Table: "resource", Name: "type"}, + Column: Column{Table: "attachment", Name: "type"}, Expressions: map[DialectName]string{}, }, "create_time": { Name: "create_time", Kind: FieldKindScalar, Type: FieldTypeTimestamp, - Column: Column{Table: "resource", Name: "created_ts"}, + Column: Column{Table: "attachment", Name: "created_ts"}, Expressions: map[DialectName]string{ // MySQL stores created_ts as TIMESTAMP, needs conversion to epoch DialectMySQL: "UNIX_TIMESTAMP(%s)", @@ -284,7 +284,7 @@ func NewAttachmentSchema() Schema { Name: "memo_id", Kind: FieldKindScalar, Type: FieldTypeInt, - Column: Column{Table: "resource", Name: "memo_id"}, + Column: Column{Table: "attachment", Name: "memo_id"}, Expressions: map[DialectName]string{}, AllowedComparisonOps: map[ComparisonOperator]bool{ CompareEq: true, diff --git a/store/db/mysql/attachment.go b/store/db/mysql/attachment.go index ead254d88..b313d34af 100644 --- a/store/db/mysql/attachment.go +++ b/store/db/mysql/attachment.go @@ -31,7 +31,7 @@ func (d *DB) CreateAttachment(ctx context.Context, create *store.Attachment) (*s } args := []any{create.UID, create.Filename, create.Blob, create.Type, create.Size, create.CreatorID, create.MemoID, storageType, create.Reference, payloadString} - stmt := "INSERT INTO `resource` (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(placeholder, ", ") + ")" + stmt := "INSERT INTO `attachment` (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(placeholder, ", ") + ")" result, err := d.db.ExecContext(ctx, stmt, args...) if err != nil { return nil, err @@ -50,38 +50,38 @@ func (d *DB) ListAttachments(ctx context.Context, find *store.FindAttachment) ([ where, args := []string{"1 = 1"}, []any{} if v := find.ID; v != nil { - where, args = append(where, "`resource`.`id` = ?"), append(args, *v) + where, args = append(where, "`attachment`.`id` = ?"), append(args, *v) } if v := find.UID; v != nil { - where, args = append(where, "`resource`.`uid` = ?"), append(args, *v) + where, args = append(where, "`attachment`.`uid` = ?"), append(args, *v) } if v := find.CreatorID; v != nil { - where, args = append(where, "`resource`.`creator_id` = ?"), append(args, *v) + where, args = append(where, "`attachment`.`creator_id` = ?"), append(args, *v) } if v := find.Filename; v != nil { - where, args = append(where, "`resource`.`filename` = ?"), append(args, *v) + where, args = append(where, "`attachment`.`filename` = ?"), append(args, *v) } if v := find.FilenameSearch; v != nil { - where, args = append(where, "`resource`.`filename` LIKE ?"), append(args, "%"+*v+"%") + where, args = append(where, "`attachment`.`filename` LIKE ?"), append(args, "%"+*v+"%") } if v := find.MemoID; v != nil { - where, args = append(where, "`resource`.`memo_id` = ?"), append(args, *v) + where, args = append(where, "`attachment`.`memo_id` = ?"), append(args, *v) } if len(find.MemoIDList) > 0 { placeholders := make([]string, 0, len(find.MemoIDList)) for range find.MemoIDList { placeholders = append(placeholders, "?") } - where = append(where, "`resource`.`memo_id` IN ("+strings.Join(placeholders, ",")+")") + where = append(where, "`attachment`.`memo_id` IN ("+strings.Join(placeholders, ",")+")") for _, id := range find.MemoIDList { args = append(args, id) } } if find.HasRelatedMemo { - where = append(where, "`resource`.`memo_id` IS NOT NULL") + where = append(where, "`attachment`.`memo_id` IS NOT NULL") } if find.StorageType != nil { - where, args = append(where, "`resource`.`storage_type` = ?"), append(args, find.StorageType.String()) + where, args = append(where, "`attachment`.`storage_type` = ?"), append(args, find.StorageType.String()) } if len(find.Filters) > 0 { @@ -95,26 +95,26 @@ func (d *DB) ListAttachments(ctx context.Context, find *store.FindAttachment) ([ } fields := []string{ - "`resource`.`id` AS `id`", - "`resource`.`uid` AS `uid`", - "`resource`.`filename` AS `filename`", - "`resource`.`type` AS `type`", - "`resource`.`size` AS `size`", - "`resource`.`creator_id` AS `creator_id`", - "UNIX_TIMESTAMP(`resource`.`created_ts`) AS `created_ts`", - "UNIX_TIMESTAMP(`resource`.`updated_ts`) AS `updated_ts`", - "`resource`.`memo_id` AS `memo_id`", - "`resource`.`storage_type` AS `storage_type`", - "`resource`.`reference` AS `reference`", - "`resource`.`payload` AS `payload`", + "`attachment`.`id` AS `id`", + "`attachment`.`uid` AS `uid`", + "`attachment`.`filename` AS `filename`", + "`attachment`.`type` AS `type`", + "`attachment`.`size` AS `size`", + "`attachment`.`creator_id` AS `creator_id`", + "UNIX_TIMESTAMP(`attachment`.`created_ts`) AS `created_ts`", + "UNIX_TIMESTAMP(`attachment`.`updated_ts`) AS `updated_ts`", + "`attachment`.`memo_id` AS `memo_id`", + "`attachment`.`storage_type` AS `storage_type`", + "`attachment`.`reference` AS `reference`", + "`attachment`.`payload` AS `payload`", "CASE WHEN `memo`.`uid` IS NOT NULL THEN `memo`.`uid` ELSE NULL END AS `memo_uid`", } if find.GetBlob { - fields = append(fields, "`resource`.`blob` AS `blob`") + fields = append(fields, "`attachment`.`blob` AS `blob`") } - query := "SELECT " + strings.Join(fields, ", ") + " FROM `resource`" + " " + - "LEFT JOIN `memo` ON `resource`.`memo_id` = `memo`.`id`" + " " + + query := "SELECT " + strings.Join(fields, ", ") + " FROM `attachment`" + " " + + "LEFT JOIN `memo` ON `attachment`.`memo_id` = `memo`.`id`" + " " + "WHERE " + strings.Join(where, " AND ") + " " + "ORDER BY `updated_ts` DESC" if find.Limit != nil { @@ -216,7 +216,7 @@ func (d *DB) UpdateAttachment(ctx context.Context, update *store.UpdateAttachmen } args = append(args, update.ID) - stmt := "UPDATE `resource` SET " + strings.Join(set, ", ") + " WHERE `id` = ?" + stmt := "UPDATE `attachment` SET " + strings.Join(set, ", ") + " WHERE `id` = ?" result, err := d.db.ExecContext(ctx, stmt, args...) if err != nil { return err @@ -228,7 +228,7 @@ func (d *DB) UpdateAttachment(ctx context.Context, update *store.UpdateAttachmen } func (d *DB) DeleteAttachment(ctx context.Context, delete *store.DeleteAttachment) error { - stmt := "DELETE FROM `resource` WHERE `id` = ?" + stmt := "DELETE FROM `attachment` WHERE `id` = ?" result, err := d.db.ExecContext(ctx, stmt, delete.ID) if err != nil { return err diff --git a/store/db/mysql/instance_setting.go b/store/db/mysql/instance_setting.go index 28c8fe529..0febb4b87 100644 --- a/store/db/mysql/instance_setting.go +++ b/store/db/mysql/instance_setting.go @@ -8,7 +8,7 @@ import ( ) func (d *DB) UpsertInstanceSetting(ctx context.Context, upsert *store.InstanceSetting) (*store.InstanceSetting, error) { - stmt := "INSERT INTO `system_setting` (`name`, `value`, `description`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `value` = ?, `description` = ?" + stmt := "INSERT INTO `instance_setting` (`name`, `value`, `description`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `value` = ?, `description` = ?" _, err := d.db.ExecContext( ctx, stmt, @@ -31,7 +31,7 @@ func (d *DB) ListInstanceSettings(ctx context.Context, find *store.FindInstanceS where, args = append(where, "`name` = ?"), append(args, find.Name) } - query := "SELECT `name`, `value`, `description` FROM `system_setting` WHERE " + strings.Join(where, " AND ") + query := "SELECT `name`, `value`, `description` FROM `instance_setting` WHERE " + strings.Join(where, " AND ") rows, err := d.db.QueryContext(ctx, query, args...) if err != nil { return nil, err @@ -59,7 +59,7 @@ func (d *DB) ListInstanceSettings(ctx context.Context, find *store.FindInstanceS } func (d *DB) DeleteInstanceSetting(ctx context.Context, delete *store.DeleteInstanceSetting) error { - stmt := "DELETE FROM `system_setting` WHERE `name` = ?" + stmt := "DELETE FROM `instance_setting` WHERE `name` = ?" _, err := d.db.ExecContext(ctx, stmt, delete.Name) return err } diff --git a/store/db/postgres/attachment.go b/store/db/postgres/attachment.go index 9ee970fbd..3d51acd2d 100644 --- a/store/db/postgres/attachment.go +++ b/store/db/postgres/attachment.go @@ -30,7 +30,7 @@ func (d *DB) CreateAttachment(ctx context.Context, create *store.Attachment) (*s } args := []any{create.UID, create.Filename, create.Blob, create.Type, create.Size, create.CreatorID, create.MemoID, storageType, create.Reference, payloadString} - stmt := "INSERT INTO resource (" + strings.Join(fields, ", ") + ") VALUES (" + placeholders(len(args)) + ") RETURNING id, created_ts, updated_ts" + stmt := "INSERT INTO attachment (" + strings.Join(fields, ", ") + ") VALUES (" + placeholders(len(args)) + ") RETURNING id, created_ts, updated_ts" if err := d.db.QueryRowContext(ctx, stmt, args...).Scan(&create.ID, &create.CreatedTs, &create.UpdatedTs); err != nil { return nil, err } @@ -41,22 +41,22 @@ func (d *DB) ListAttachments(ctx context.Context, find *store.FindAttachment) ([ where, args := []string{"1 = 1"}, []any{} if v := find.ID; v != nil { - where, args = append(where, "resource.id = "+placeholder(len(args)+1)), append(args, *v) + where, args = append(where, "attachment.id = "+placeholder(len(args)+1)), append(args, *v) } if v := find.UID; v != nil { - where, args = append(where, "resource.uid = "+placeholder(len(args)+1)), append(args, *v) + where, args = append(where, "attachment.uid = "+placeholder(len(args)+1)), append(args, *v) } if v := find.CreatorID; v != nil { - where, args = append(where, "resource.creator_id = "+placeholder(len(args)+1)), append(args, *v) + where, args = append(where, "attachment.creator_id = "+placeholder(len(args)+1)), append(args, *v) } if v := find.Filename; v != nil { - where, args = append(where, "resource.filename = "+placeholder(len(args)+1)), append(args, *v) + where, args = append(where, "attachment.filename = "+placeholder(len(args)+1)), append(args, *v) } if v := find.FilenameSearch; v != nil { - where, args = append(where, "resource.filename LIKE "+placeholder(len(args)+1)), append(args, fmt.Sprintf("%%%s%%", *v)) + where, args = append(where, "attachment.filename LIKE "+placeholder(len(args)+1)), append(args, fmt.Sprintf("%%%s%%", *v)) } if v := find.MemoID; v != nil { - where, args = append(where, "resource.memo_id = "+placeholder(len(args)+1)), append(args, *v) + where, args = append(where, "attachment.memo_id = "+placeholder(len(args)+1)), append(args, *v) } if len(find.MemoIDList) > 0 { holders := make([]string, 0, len(find.MemoIDList)) @@ -64,13 +64,13 @@ func (d *DB) ListAttachments(ctx context.Context, find *store.FindAttachment) ([ holders = append(holders, placeholder(len(args)+1)) args = append(args, id) } - where = append(where, "resource.memo_id IN ("+strings.Join(holders, ", ")+")") + where = append(where, "attachment.memo_id IN ("+strings.Join(holders, ", ")+")") } if find.HasRelatedMemo { - where = append(where, "resource.memo_id IS NOT NULL") + where = append(where, "attachment.memo_id IS NOT NULL") } if v := find.StorageType; v != nil { - where, args = append(where, "resource.storage_type = "+placeholder(len(args)+1)), append(args, v.String()) + where, args = append(where, "attachment.storage_type = "+placeholder(len(args)+1)), append(args, v.String()) } if len(find.Filters) > 0 { @@ -84,31 +84,31 @@ func (d *DB) ListAttachments(ctx context.Context, find *store.FindAttachment) ([ } fields := []string{ - "resource.id AS id", - "resource.uid AS uid", - "resource.filename AS filename", - "resource.type AS type", - "resource.size AS size", - "resource.creator_id AS creator_id", - "resource.created_ts AS created_ts", - "resource.updated_ts AS updated_ts", - "resource.memo_id AS memo_id", - "resource.storage_type AS storage_type", - "resource.reference AS reference", - "resource.payload AS payload", + "attachment.id AS id", + "attachment.uid AS uid", + "attachment.filename AS filename", + "attachment.type AS type", + "attachment.size AS size", + "attachment.creator_id AS creator_id", + "attachment.created_ts AS created_ts", + "attachment.updated_ts AS updated_ts", + "attachment.memo_id AS memo_id", + "attachment.storage_type AS storage_type", + "attachment.reference AS reference", + "attachment.payload AS payload", "CASE WHEN memo.uid IS NOT NULL THEN memo.uid ELSE NULL END AS memo_uid", } if find.GetBlob { - fields = append(fields, "resource.blob AS blob") + fields = append(fields, "attachment.blob AS blob") } query := fmt.Sprintf(` SELECT %s - FROM resource - LEFT JOIN memo ON resource.memo_id = memo.id + FROM attachment + LEFT JOIN memo ON attachment.memo_id = memo.id WHERE %s - ORDER BY resource.updated_ts DESC + ORDER BY attachment.updated_ts DESC `, strings.Join(fields, ", "), strings.Join(where, " AND ")) if find.Limit != nil { query = fmt.Sprintf("%s LIMIT %d", query, *find.Limit) @@ -196,7 +196,7 @@ func (d *DB) UpdateAttachment(ctx context.Context, update *store.UpdateAttachmen set, args = append(set, "payload = "+placeholder(len(args)+1)), append(args, string(bytes)) } - stmt := `UPDATE resource SET ` + strings.Join(set, ", ") + ` WHERE id = ` + placeholder(len(args)+1) + stmt := `UPDATE attachment SET ` + strings.Join(set, ", ") + ` WHERE id = ` + placeholder(len(args)+1) args = append(args, update.ID) result, err := d.db.ExecContext(ctx, stmt, args...) if err != nil { @@ -209,7 +209,7 @@ func (d *DB) UpdateAttachment(ctx context.Context, update *store.UpdateAttachmen } func (d *DB) DeleteAttachment(ctx context.Context, delete *store.DeleteAttachment) error { - stmt := `DELETE FROM resource WHERE id = $1` + stmt := `DELETE FROM attachment WHERE id = $1` result, err := d.db.ExecContext(ctx, stmt, delete.ID) if err != nil { return err diff --git a/store/db/postgres/instance_setting.go b/store/db/postgres/instance_setting.go index a2ec78c3e..5a6621291 100644 --- a/store/db/postgres/instance_setting.go +++ b/store/db/postgres/instance_setting.go @@ -9,7 +9,7 @@ import ( func (d *DB) UpsertInstanceSetting(ctx context.Context, upsert *store.InstanceSetting) (*store.InstanceSetting, error) { stmt := ` - INSERT INTO system_setting ( + INSERT INTO instance_setting ( name, value, description ) VALUES ($1, $2, $3) @@ -36,7 +36,7 @@ func (d *DB) ListInstanceSettings(ctx context.Context, find *store.FindInstanceS name, value, description - FROM system_setting + FROM instance_setting WHERE ` + strings.Join(where, " AND ") rows, err := d.db.QueryContext(ctx, query, args...) @@ -66,7 +66,7 @@ func (d *DB) ListInstanceSettings(ctx context.Context, find *store.FindInstanceS } func (d *DB) DeleteInstanceSetting(ctx context.Context, delete *store.DeleteInstanceSetting) error { - stmt := `DELETE FROM system_setting WHERE name = $1` + stmt := `DELETE FROM instance_setting WHERE name = $1` _, err := d.db.ExecContext(ctx, stmt, delete.Name) return err } diff --git a/store/db/sqlite/attachment.go b/store/db/sqlite/attachment.go index 04653b185..3ac8afd6f 100644 --- a/store/db/sqlite/attachment.go +++ b/store/db/sqlite/attachment.go @@ -31,7 +31,7 @@ func (d *DB) CreateAttachment(ctx context.Context, create *store.Attachment) (*s } args := []any{create.UID, create.Filename, create.Blob, create.Type, create.Size, create.CreatorID, create.MemoID, storageType, create.Reference, payloadString} - stmt := "INSERT INTO `resource` (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(placeholder, ", ") + ") RETURNING `id`, `created_ts`, `updated_ts`" + stmt := "INSERT INTO `attachment` (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(placeholder, ", ") + ") RETURNING `id`, `created_ts`, `updated_ts`" if err := d.db.QueryRowContext(ctx, stmt, args...).Scan(&create.ID, &create.CreatedTs, &create.UpdatedTs); err != nil { return nil, err } @@ -43,38 +43,38 @@ func (d *DB) ListAttachments(ctx context.Context, find *store.FindAttachment) ([ where, args := []string{"1 = 1"}, []any{} if v := find.ID; v != nil { - where, args = append(where, "`resource`.`id` = ?"), append(args, *v) + where, args = append(where, "`attachment`.`id` = ?"), append(args, *v) } if v := find.UID; v != nil { - where, args = append(where, "`resource`.`uid` = ?"), append(args, *v) + where, args = append(where, "`attachment`.`uid` = ?"), append(args, *v) } if v := find.CreatorID; v != nil { - where, args = append(where, "`resource`.`creator_id` = ?"), append(args, *v) + where, args = append(where, "`attachment`.`creator_id` = ?"), append(args, *v) } if v := find.Filename; v != nil { - where, args = append(where, "`resource`.`filename` = ?"), append(args, *v) + where, args = append(where, "`attachment`.`filename` = ?"), append(args, *v) } if v := find.FilenameSearch; v != nil { - where, args = append(where, "`resource`.`filename` LIKE ?"), append(args, fmt.Sprintf("%%%s%%", *v)) + where, args = append(where, "`attachment`.`filename` LIKE ?"), append(args, fmt.Sprintf("%%%s%%", *v)) } if v := find.MemoID; v != nil { - where, args = append(where, "`resource`.`memo_id` = ?"), append(args, *v) + where, args = append(where, "`attachment`.`memo_id` = ?"), append(args, *v) } if len(find.MemoIDList) > 0 { placeholders := make([]string, 0, len(find.MemoIDList)) for range find.MemoIDList { placeholders = append(placeholders, "?") } - where = append(where, "`resource`.`memo_id` IN ("+strings.Join(placeholders, ",")+")") + where = append(where, "`attachment`.`memo_id` IN ("+strings.Join(placeholders, ",")+")") for _, id := range find.MemoIDList { args = append(args, id) } } if find.HasRelatedMemo { - where = append(where, "`resource`.`memo_id` IS NOT NULL") + where = append(where, "`attachment`.`memo_id` IS NOT NULL") } if find.StorageType != nil { - where, args = append(where, "`resource`.`storage_type` = ?"), append(args, find.StorageType.String()) + where, args = append(where, "`attachment`.`storage_type` = ?"), append(args, find.StorageType.String()) } if len(find.Filters) > 0 { @@ -88,28 +88,28 @@ func (d *DB) ListAttachments(ctx context.Context, find *store.FindAttachment) ([ } fields := []string{ - "`resource`.`id` AS `id`", - "`resource`.`uid` AS `uid`", - "`resource`.`filename` AS `filename`", - "`resource`.`type` AS `type`", - "`resource`.`size` AS `size`", - "`resource`.`creator_id` AS `creator_id`", - "`resource`.`created_ts` AS `created_ts`", - "`resource`.`updated_ts` AS `updated_ts`", - "`resource`.`memo_id` AS `memo_id`", - "`resource`.`storage_type` AS `storage_type`", - "`resource`.`reference` AS `reference`", - "`resource`.`payload` AS `payload`", + "`attachment`.`id` AS `id`", + "`attachment`.`uid` AS `uid`", + "`attachment`.`filename` AS `filename`", + "`attachment`.`type` AS `type`", + "`attachment`.`size` AS `size`", + "`attachment`.`creator_id` AS `creator_id`", + "`attachment`.`created_ts` AS `created_ts`", + "`attachment`.`updated_ts` AS `updated_ts`", + "`attachment`.`memo_id` AS `memo_id`", + "`attachment`.`storage_type` AS `storage_type`", + "`attachment`.`reference` AS `reference`", + "`attachment`.`payload` AS `payload`", "CASE WHEN `memo`.`uid` IS NOT NULL THEN `memo`.`uid` ELSE NULL END AS `memo_uid`", } if find.GetBlob { - fields = append(fields, "`resource`.`blob` AS `blob`") + fields = append(fields, "`attachment`.`blob` AS `blob`") } - query := "SELECT " + strings.Join(fields, ", ") + " FROM `resource`" + " " + - "LEFT JOIN `memo` ON `resource`.`memo_id` = `memo`.`id`" + " " + + query := "SELECT " + strings.Join(fields, ", ") + " FROM `attachment`" + " " + + "LEFT JOIN `memo` ON `attachment`.`memo_id` = `memo`.`id`" + " " + "WHERE " + strings.Join(where, " AND ") + " " + - "ORDER BY `resource`.`updated_ts` DESC" + "ORDER BY `attachment`.`updated_ts` DESC" if find.Limit != nil { query = fmt.Sprintf("%s LIMIT %d", query, *find.Limit) if find.Offset != nil { @@ -197,7 +197,7 @@ func (d *DB) UpdateAttachment(ctx context.Context, update *store.UpdateAttachmen } args = append(args, update.ID) - stmt := "UPDATE `resource` SET " + strings.Join(set, ", ") + " WHERE `id` = ?" + stmt := "UPDATE `attachment` SET " + strings.Join(set, ", ") + " WHERE `id` = ?" result, err := d.db.ExecContext(ctx, stmt, args...) if err != nil { return errors.Wrap(err, "failed to update attachment") @@ -209,7 +209,7 @@ func (d *DB) UpdateAttachment(ctx context.Context, update *store.UpdateAttachmen } func (d *DB) DeleteAttachment(ctx context.Context, delete *store.DeleteAttachment) error { - stmt := "DELETE FROM `resource` WHERE `id` = ?" + stmt := "DELETE FROM `attachment` WHERE `id` = ?" result, err := d.db.ExecContext(ctx, stmt, delete.ID) if err != nil { return err diff --git a/store/db/sqlite/instance_setting.go b/store/db/sqlite/instance_setting.go index 658501fc8..d91dd35b9 100644 --- a/store/db/sqlite/instance_setting.go +++ b/store/db/sqlite/instance_setting.go @@ -9,7 +9,7 @@ import ( func (d *DB) UpsertInstanceSetting(ctx context.Context, upsert *store.InstanceSetting) (*store.InstanceSetting, error) { stmt := ` - INSERT INTO system_setting ( + INSERT INTO instance_setting ( name, value, description ) VALUES (?, ?, ?) @@ -36,7 +36,7 @@ func (d *DB) ListInstanceSettings(ctx context.Context, find *store.FindInstanceS name, value, description - FROM system_setting + FROM instance_setting WHERE ` + strings.Join(where, " AND ") rows, err := d.db.QueryContext(ctx, query, args...) @@ -66,7 +66,7 @@ func (d *DB) ListInstanceSettings(ctx context.Context, find *store.FindInstanceS } func (d *DB) DeleteInstanceSetting(ctx context.Context, delete *store.DeleteInstanceSetting) error { - stmt := "DELETE FROM system_setting WHERE name = ?" + stmt := "DELETE FROM instance_setting WHERE name = ?" _, err := d.db.ExecContext(ctx, stmt, delete.Name) return err } diff --git a/store/migration/mysql/0.26/00__rename_resource_to_attachment.sql b/store/migration/mysql/0.26/00__rename_resource_to_attachment.sql new file mode 100644 index 000000000..703234fe3 --- /dev/null +++ b/store/migration/mysql/0.26/00__rename_resource_to_attachment.sql @@ -0,0 +1 @@ +RENAME TABLE resource TO attachment; diff --git a/store/migration/mysql/0.26/01__rename_system_setting_to_instance_setting.sql b/store/migration/mysql/0.26/01__rename_system_setting_to_instance_setting.sql new file mode 100644 index 000000000..cb8481a93 --- /dev/null +++ b/store/migration/mysql/0.26/01__rename_system_setting_to_instance_setting.sql @@ -0,0 +1 @@ +RENAME TABLE system_setting TO instance_setting; diff --git a/store/migration/mysql/LATEST.sql b/store/migration/mysql/LATEST.sql index adc86a9eb..8613d2fac 100644 --- a/store/migration/mysql/LATEST.sql +++ b/store/migration/mysql/LATEST.sql @@ -1,5 +1,5 @@ --- system_setting -CREATE TABLE `system_setting` ( +-- instance_setting +CREATE TABLE `instance_setting` ( `name` VARCHAR(256) NOT NULL PRIMARY KEY, `value` LONGTEXT NOT NULL, `description` TEXT NOT NULL @@ -58,8 +58,8 @@ CREATE TABLE `memo_relation` ( UNIQUE(`memo_id`,`related_memo_id`,`type`) ); --- resource -CREATE TABLE `resource` ( +-- attachment +CREATE TABLE `attachment` ( `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, `uid` VARCHAR(256) NOT NULL UNIQUE, `creator_id` INT NOT NULL, diff --git a/store/migration/postgres/0.26/00__rename_resource_to_attachment.sql b/store/migration/postgres/0.26/00__rename_resource_to_attachment.sql new file mode 100644 index 000000000..9e0e4396e --- /dev/null +++ b/store/migration/postgres/0.26/00__rename_resource_to_attachment.sql @@ -0,0 +1 @@ +ALTER TABLE resource RENAME TO attachment; diff --git a/store/migration/postgres/0.26/01__rename_system_setting_to_instance_setting.sql b/store/migration/postgres/0.26/01__rename_system_setting_to_instance_setting.sql new file mode 100644 index 000000000..057edb702 --- /dev/null +++ b/store/migration/postgres/0.26/01__rename_system_setting_to_instance_setting.sql @@ -0,0 +1 @@ +ALTER TABLE system_setting RENAME TO instance_setting; diff --git a/store/migration/postgres/LATEST.sql b/store/migration/postgres/LATEST.sql index b5b70a9ec..f227777a5 100644 --- a/store/migration/postgres/LATEST.sql +++ b/store/migration/postgres/LATEST.sql @@ -1,5 +1,5 @@ --- system_setting -CREATE TABLE system_setting ( +-- instance_setting +CREATE TABLE instance_setting ( name TEXT NOT NULL PRIMARY KEY, value TEXT NOT NULL, description TEXT NOT NULL @@ -58,8 +58,8 @@ CREATE TABLE memo_relation ( UNIQUE(memo_id, related_memo_id, type) ); --- resource -CREATE TABLE resource ( +-- attachment +CREATE TABLE attachment ( id SERIAL PRIMARY KEY, uid TEXT NOT NULL UNIQUE, creator_id INTEGER NOT NULL, diff --git a/store/migration/sqlite/0.26/00__rename_resource_to_attachment.sql b/store/migration/sqlite/0.26/00__rename_resource_to_attachment.sql new file mode 100644 index 000000000..151cd6d3a --- /dev/null +++ b/store/migration/sqlite/0.26/00__rename_resource_to_attachment.sql @@ -0,0 +1,5 @@ +ALTER TABLE `resource` RENAME TO `attachment`; +DROP INDEX IF EXISTS `idx_resource_creator_id`; +CREATE INDEX `idx_attachment_creator_id` ON `attachment` (`creator_id`); +DROP INDEX IF EXISTS `idx_resource_memo_id`; +CREATE INDEX `idx_attachment_memo_id` ON `attachment` (`memo_id`); diff --git a/store/migration/sqlite/0.26/01__rename_system_setting_to_instance_setting.sql b/store/migration/sqlite/0.26/01__rename_system_setting_to_instance_setting.sql new file mode 100644 index 000000000..84ed64a1b --- /dev/null +++ b/store/migration/sqlite/0.26/01__rename_system_setting_to_instance_setting.sql @@ -0,0 +1 @@ +ALTER TABLE `system_setting` RENAME TO `instance_setting`; diff --git a/store/migration/sqlite/LATEST.sql b/store/migration/sqlite/LATEST.sql index 6a36e9338..b3a26d29d 100644 --- a/store/migration/sqlite/LATEST.sql +++ b/store/migration/sqlite/LATEST.sql @@ -1,5 +1,5 @@ --- system_setting -CREATE TABLE system_setting ( +-- instance_setting +CREATE TABLE instance_setting ( name TEXT NOT NULL, value TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', @@ -63,8 +63,8 @@ CREATE TABLE memo_relation ( UNIQUE(memo_id, related_memo_id, type) ); --- resource -CREATE TABLE resource ( +-- attachment +CREATE TABLE attachment ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT NOT NULL UNIQUE, creator_id INTEGER NOT NULL, @@ -80,9 +80,9 @@ CREATE TABLE resource ( payload TEXT NOT NULL DEFAULT '{}' ); -CREATE INDEX idx_resource_creator_id ON resource (creator_id); +CREATE INDEX idx_attachment_creator_id ON attachment (creator_id); -CREATE INDEX idx_resource_memo_id ON resource (memo_id); +CREATE INDEX idx_attachment_memo_id ON attachment (memo_id); -- activity CREATE TABLE activity ( diff --git a/store/seed/sqlite/00__reset.sql b/store/seed/sqlite/00__reset.sql deleted file mode 100644 index 65e32e7b6..000000000 --- a/store/seed/sqlite/00__reset.sql +++ /dev/null @@ -1,11 +0,0 @@ -DELETE FROM system_setting; -DELETE FROM user; -DELETE FROM user_setting; -DELETE FROM memo; -DELETE FROM memo_organizer; -DELETE FROM memo_relation; -DELETE FROM resource; -DELETE FROM activity; -DELETE FROM idp; -DELETE FROM inbox; -DELETE FROM reaction; From 64b487d4afada3d8089ada353178c10da78f981b Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 7 Jan 2026 08:34:42 +0800 Subject: [PATCH 02/86] chore: fix seed data --- store/seed/sqlite/01__dump.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/store/seed/sqlite/01__dump.sql b/store/seed/sqlite/01__dump.sql index 457703998..c42ee5181 100644 --- a/store/seed/sqlite/01__dump.sql +++ b/store/seed/sqlite/01__dump.sql @@ -36,4 +36,4 @@ INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(4,1,'memos/ INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(5,1,'memos/sponsor0000001','👍'); -- System Settings -INSERT INTO system_setting VALUES ('MEMO_RELATED', '{"contentLengthLimit":8192,"enableAutoCompact":true,"enableComment":true,"enableLocation":true,"defaultVisibility":"PUBLIC","reactions":["👍","💛","🔥","👏","😂","👌","🚀","👀","🤔","🤡","❓","+1","🎉","💡","✅"]}', ''); +INSERT INTO instance_setting VALUES ('MEMO_RELATED', '{"contentLengthLimit":8192,"enableAutoCompact":true,"enableComment":true,"enableLocation":true,"defaultVisibility":"PUBLIC","reactions":["👍","💛","🔥","👏","😂","👌","🚀","👀","🤔","🤡","❓","+1","🎉","💡","✅"]}', ''); From 9ccb658768c9f6d0597aa43a7549735b08c424f4 Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 7 Jan 2026 08:50:10 +0800 Subject: [PATCH 03/86] fix: sign up redirect --- web/src/pages/SignUp.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/web/src/pages/SignUp.tsx b/web/src/pages/SignUp.tsx index c83bd320c..f7a6429ee 100644 --- a/web/src/pages/SignUp.tsx +++ b/web/src/pages/SignUp.tsx @@ -9,22 +9,18 @@ import AuthFooter from "@/components/AuthFooter"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { authServiceClient, userServiceClient } from "@/connect"; -import { useAuth } from "@/contexts/AuthContext"; import { useInstance } from "@/contexts/InstanceContext"; import useLoading from "@/hooks/useLoading"; -import useNavigateTo from "@/hooks/useNavigateTo"; import { handleError } from "@/lib/error"; import { User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; const SignUp = () => { const t = useTranslate(); - const navigateTo = useNavigateTo(); const actionBtnLoadingState = useLoading(false); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const { generalSetting: instanceGeneralSetting, profile } = useInstance(); - const { initialize } = useAuth(); const handleUsernameInputChanged = (e: React.ChangeEvent) => { const text = e.target.value as string; @@ -68,8 +64,7 @@ const SignUp = () => { if (response.accessToken) { setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined); } - await initialize(); - navigateTo("/"); + window.location.href = "/"; } catch (error: unknown) { handleError(error, toast.error, { fallbackMessage: "Sign up failed", From 79f1edc9ba6ea4a5373bf3a9b68f3ea559336496 Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 7 Jan 2026 09:08:20 +0800 Subject: [PATCH 04/86] chore: bump version --- internal/version/version.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/version/version.go b/internal/version/version.go index 2fdc62aef..cae194088 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -9,10 +9,10 @@ import ( // Version is the service current released version. // Semantic versioning: https://semver.org/ -var Version = "0.25.3" +var Version = "0.26.0" // DevVersion is the service current development version. -var DevVersion = "0.25.3" +var DevVersion = "0.26.0" func GetCurrentVersion(mode string) string { if mode == "dev" || mode == "demo" { From e75862de31b65f90007d771fd623e1d23261b2c6 Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 7 Jan 2026 09:17:34 +0800 Subject: [PATCH 05/86] chore: fix tests --- store/test/migrator_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/store/test/migrator_test.go b/store/test/migrator_test.go index a76f27ee1..ce510d503 100644 --- a/store/test/migrator_test.go +++ b/store/test/migrator_test.go @@ -13,5 +13,5 @@ func TestGetCurrentSchemaVersion(t *testing.T) { currentSchemaVersion, err := ts.GetCurrentSchemaVersion() require.NoError(t, err) - require.Equal(t, "0.25.1", currentSchemaVersion) + require.Equal(t, "0.26.2", currentSchemaVersion) } From cc9a214be8b20566ee0c311bf134fcf813ddcc41 Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 7 Jan 2026 20:38:02 +0800 Subject: [PATCH 06/86] revert: revert system_setting to instance_setting rename changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts only the system_setting → instance_setting rename related changes from commit d326c71. Keeps the resource → attachment rename changes intact. - Reverts table name back to system_setting in all database drivers (MySQL, PostgreSQL, SQLite) - Removes migration files for the system_setting rename - Reverts LATEST.sql files to use system_setting table --- store/db/mysql/instance_setting.go | 6 +++--- store/db/postgres/instance_setting.go | 6 +++--- store/db/sqlite/instance_setting.go | 6 +++--- ...01__rename_system_setting_to_instance_setting.sql | 1 - store/migration/mysql/LATEST.sql | 8 ++++---- ...01__rename_system_setting_to_instance_setting.sql | 1 - store/migration/postgres/LATEST.sql | 8 ++++---- ...01__rename_system_setting_to_instance_setting.sql | 1 - store/migration/sqlite/LATEST.sql | 12 ++++++------ store/test/migrator_test.go | 2 +- 10 files changed, 24 insertions(+), 27 deletions(-) delete mode 100644 store/migration/mysql/0.26/01__rename_system_setting_to_instance_setting.sql delete mode 100644 store/migration/postgres/0.26/01__rename_system_setting_to_instance_setting.sql delete mode 100644 store/migration/sqlite/0.26/01__rename_system_setting_to_instance_setting.sql diff --git a/store/db/mysql/instance_setting.go b/store/db/mysql/instance_setting.go index 0febb4b87..28c8fe529 100644 --- a/store/db/mysql/instance_setting.go +++ b/store/db/mysql/instance_setting.go @@ -8,7 +8,7 @@ import ( ) func (d *DB) UpsertInstanceSetting(ctx context.Context, upsert *store.InstanceSetting) (*store.InstanceSetting, error) { - stmt := "INSERT INTO `instance_setting` (`name`, `value`, `description`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `value` = ?, `description` = ?" + stmt := "INSERT INTO `system_setting` (`name`, `value`, `description`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `value` = ?, `description` = ?" _, err := d.db.ExecContext( ctx, stmt, @@ -31,7 +31,7 @@ func (d *DB) ListInstanceSettings(ctx context.Context, find *store.FindInstanceS where, args = append(where, "`name` = ?"), append(args, find.Name) } - query := "SELECT `name`, `value`, `description` FROM `instance_setting` WHERE " + strings.Join(where, " AND ") + query := "SELECT `name`, `value`, `description` FROM `system_setting` WHERE " + strings.Join(where, " AND ") rows, err := d.db.QueryContext(ctx, query, args...) if err != nil { return nil, err @@ -59,7 +59,7 @@ func (d *DB) ListInstanceSettings(ctx context.Context, find *store.FindInstanceS } func (d *DB) DeleteInstanceSetting(ctx context.Context, delete *store.DeleteInstanceSetting) error { - stmt := "DELETE FROM `instance_setting` WHERE `name` = ?" + stmt := "DELETE FROM `system_setting` WHERE `name` = ?" _, err := d.db.ExecContext(ctx, stmt, delete.Name) return err } diff --git a/store/db/postgres/instance_setting.go b/store/db/postgres/instance_setting.go index 5a6621291..a2ec78c3e 100644 --- a/store/db/postgres/instance_setting.go +++ b/store/db/postgres/instance_setting.go @@ -9,7 +9,7 @@ import ( func (d *DB) UpsertInstanceSetting(ctx context.Context, upsert *store.InstanceSetting) (*store.InstanceSetting, error) { stmt := ` - INSERT INTO instance_setting ( + INSERT INTO system_setting ( name, value, description ) VALUES ($1, $2, $3) @@ -36,7 +36,7 @@ func (d *DB) ListInstanceSettings(ctx context.Context, find *store.FindInstanceS name, value, description - FROM instance_setting + FROM system_setting WHERE ` + strings.Join(where, " AND ") rows, err := d.db.QueryContext(ctx, query, args...) @@ -66,7 +66,7 @@ func (d *DB) ListInstanceSettings(ctx context.Context, find *store.FindInstanceS } func (d *DB) DeleteInstanceSetting(ctx context.Context, delete *store.DeleteInstanceSetting) error { - stmt := `DELETE FROM instance_setting WHERE name = $1` + stmt := `DELETE FROM system_setting WHERE name = $1` _, err := d.db.ExecContext(ctx, stmt, delete.Name) return err } diff --git a/store/db/sqlite/instance_setting.go b/store/db/sqlite/instance_setting.go index d91dd35b9..658501fc8 100644 --- a/store/db/sqlite/instance_setting.go +++ b/store/db/sqlite/instance_setting.go @@ -9,7 +9,7 @@ import ( func (d *DB) UpsertInstanceSetting(ctx context.Context, upsert *store.InstanceSetting) (*store.InstanceSetting, error) { stmt := ` - INSERT INTO instance_setting ( + INSERT INTO system_setting ( name, value, description ) VALUES (?, ?, ?) @@ -36,7 +36,7 @@ func (d *DB) ListInstanceSettings(ctx context.Context, find *store.FindInstanceS name, value, description - FROM instance_setting + FROM system_setting WHERE ` + strings.Join(where, " AND ") rows, err := d.db.QueryContext(ctx, query, args...) @@ -66,7 +66,7 @@ func (d *DB) ListInstanceSettings(ctx context.Context, find *store.FindInstanceS } func (d *DB) DeleteInstanceSetting(ctx context.Context, delete *store.DeleteInstanceSetting) error { - stmt := "DELETE FROM instance_setting WHERE name = ?" + stmt := "DELETE FROM system_setting WHERE name = ?" _, err := d.db.ExecContext(ctx, stmt, delete.Name) return err } diff --git a/store/migration/mysql/0.26/01__rename_system_setting_to_instance_setting.sql b/store/migration/mysql/0.26/01__rename_system_setting_to_instance_setting.sql deleted file mode 100644 index cb8481a93..000000000 --- a/store/migration/mysql/0.26/01__rename_system_setting_to_instance_setting.sql +++ /dev/null @@ -1 +0,0 @@ -RENAME TABLE system_setting TO instance_setting; diff --git a/store/migration/mysql/LATEST.sql b/store/migration/mysql/LATEST.sql index 8613d2fac..adc86a9eb 100644 --- a/store/migration/mysql/LATEST.sql +++ b/store/migration/mysql/LATEST.sql @@ -1,5 +1,5 @@ --- instance_setting -CREATE TABLE `instance_setting` ( +-- system_setting +CREATE TABLE `system_setting` ( `name` VARCHAR(256) NOT NULL PRIMARY KEY, `value` LONGTEXT NOT NULL, `description` TEXT NOT NULL @@ -58,8 +58,8 @@ CREATE TABLE `memo_relation` ( UNIQUE(`memo_id`,`related_memo_id`,`type`) ); --- attachment -CREATE TABLE `attachment` ( +-- resource +CREATE TABLE `resource` ( `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, `uid` VARCHAR(256) NOT NULL UNIQUE, `creator_id` INT NOT NULL, diff --git a/store/migration/postgres/0.26/01__rename_system_setting_to_instance_setting.sql b/store/migration/postgres/0.26/01__rename_system_setting_to_instance_setting.sql deleted file mode 100644 index 057edb702..000000000 --- a/store/migration/postgres/0.26/01__rename_system_setting_to_instance_setting.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE system_setting RENAME TO instance_setting; diff --git a/store/migration/postgres/LATEST.sql b/store/migration/postgres/LATEST.sql index f227777a5..b5b70a9ec 100644 --- a/store/migration/postgres/LATEST.sql +++ b/store/migration/postgres/LATEST.sql @@ -1,5 +1,5 @@ --- instance_setting -CREATE TABLE instance_setting ( +-- system_setting +CREATE TABLE system_setting ( name TEXT NOT NULL PRIMARY KEY, value TEXT NOT NULL, description TEXT NOT NULL @@ -58,8 +58,8 @@ CREATE TABLE memo_relation ( UNIQUE(memo_id, related_memo_id, type) ); --- attachment -CREATE TABLE attachment ( +-- resource +CREATE TABLE resource ( id SERIAL PRIMARY KEY, uid TEXT NOT NULL UNIQUE, creator_id INTEGER NOT NULL, diff --git a/store/migration/sqlite/0.26/01__rename_system_setting_to_instance_setting.sql b/store/migration/sqlite/0.26/01__rename_system_setting_to_instance_setting.sql deleted file mode 100644 index 84ed64a1b..000000000 --- a/store/migration/sqlite/0.26/01__rename_system_setting_to_instance_setting.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `system_setting` RENAME TO `instance_setting`; diff --git a/store/migration/sqlite/LATEST.sql b/store/migration/sqlite/LATEST.sql index b3a26d29d..6a36e9338 100644 --- a/store/migration/sqlite/LATEST.sql +++ b/store/migration/sqlite/LATEST.sql @@ -1,5 +1,5 @@ --- instance_setting -CREATE TABLE instance_setting ( +-- system_setting +CREATE TABLE system_setting ( name TEXT NOT NULL, value TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', @@ -63,8 +63,8 @@ CREATE TABLE memo_relation ( UNIQUE(memo_id, related_memo_id, type) ); --- attachment -CREATE TABLE attachment ( +-- resource +CREATE TABLE resource ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT NOT NULL UNIQUE, creator_id INTEGER NOT NULL, @@ -80,9 +80,9 @@ CREATE TABLE attachment ( payload TEXT NOT NULL DEFAULT '{}' ); -CREATE INDEX idx_attachment_creator_id ON attachment (creator_id); +CREATE INDEX idx_resource_creator_id ON resource (creator_id); -CREATE INDEX idx_attachment_memo_id ON attachment (memo_id); +CREATE INDEX idx_resource_memo_id ON resource (memo_id); -- activity CREATE TABLE activity ( diff --git a/store/test/migrator_test.go b/store/test/migrator_test.go index ce510d503..946d9ce10 100644 --- a/store/test/migrator_test.go +++ b/store/test/migrator_test.go @@ -13,5 +13,5 @@ func TestGetCurrentSchemaVersion(t *testing.T) { currentSchemaVersion, err := ts.GetCurrentSchemaVersion() require.NoError(t, err) - require.Equal(t, "0.26.2", currentSchemaVersion) + require.Equal(t, "0.26.1", currentSchemaVersion) } From c29c1d3e1fe808ca03f8b02a42f40d468f6f8805 Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 7 Jan 2026 20:41:16 +0800 Subject: [PATCH 07/86] fix: seed data --- AGENTS.md | 9 +-------- store/migrator.go | 12 ++++++------ store/seed/sqlite/01__dump.sql | 2 +- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fe20cce0d..c98cb7a3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,7 +165,7 @@ type Driver interface { 4. Demo mode: Seed with demo data **Schema Versioning:** -- Stored in `instance_setting` table (key: `bb.general.version`) +- Stored in `system_setting` table - Format: `major.minor.patch` - Migration files: `store/migration/{driver}/{version}/NN__description.sql` - See: `store/migrator.go:21-414` @@ -503,13 +503,6 @@ cd web && pnpm lint ## Common Tasks -### Debugging Database Issues - -1. Check connection string in logs -2. Verify `store/db/{driver}/migration/` files exist -3. Check schema version: `SELECT * FROM instance_setting WHERE key = 'bb.general.version'` -4. Test migration: `go test ./store/test/... -v` - ### Debugging API Issues 1. Check Connect interceptor logs: `server/router/api/v1/connect_interceptors.go:79-105` diff --git a/store/migrator.go b/store/migrator.go index d5446fcab..1a8bb5d39 100644 --- a/store/migrator.go +++ b/store/migrator.go @@ -21,7 +21,7 @@ import ( // Migration System Overview: // // The migration system handles database schema versioning and upgrades. -// Schema version is stored in instance_setting (formerly system_setting). +// Schema version is stored in system_setting. // // Migration Flow: // 1. preMigrate: Check if DB is initialized. If not, apply LATEST.sql @@ -30,9 +30,9 @@ import ( // 4. Migrate (demo mode): Seed database with demo data // // Version Tracking: -// - New installations: Schema version set in instance_setting immediately -// - Existing v0.22+ installations: Schema version tracked in instance_setting -// - Pre-v0.22 installations: Must upgrade to v0.25.x first (migration_history → instance_setting migration) +// - New installations: Schema version set in system_setting immediately +// - Existing v0.22+ installations: Schema version tracked in system_setting +// - Pre-v0.22 installations: Must upgrade to v0.25.x first (migration_history → system_setting migration) // // Migration Files: // - Location: store/migration/{driver}/{version}/NN__description.sql @@ -373,7 +373,7 @@ func (s *Store) updateCurrentSchemaVersion(ctx context.Context, schemaVersion st // checkMinimumUpgradeVersion verifies the installation meets minimum version requirements for upgrade. // For very old installations (< v0.22.0), users must upgrade to v0.25.x first before upgrading to current version. -// This is necessary because schema version tracking was moved from migration_history to instance_setting in v0.22.0. +// This is necessary because schema version tracking was moved from migration_history to system_setting in v0.22.0. func (s *Store) checkMinimumUpgradeVersion(ctx context.Context) error { instanceBasicSetting, err := s.GetInstanceBasicSetting(ctx) if err != nil { @@ -401,7 +401,7 @@ func (s *Store) checkMinimumUpgradeVersion(ctx context.Context) error { "2. Start the server and verify it works\n"+ "3. Then upgrade to the latest version\n\n"+ "This is required because schema version tracking was moved from migration_history\n"+ - "to instance_setting in v0.22.0. The intermediate upgrade handles this migration safely.", + "to system_setting in v0.22.0. The intermediate upgrade handles this migration safely.", schemaVersion, currentVersion, ) diff --git a/store/seed/sqlite/01__dump.sql b/store/seed/sqlite/01__dump.sql index c42ee5181..457703998 100644 --- a/store/seed/sqlite/01__dump.sql +++ b/store/seed/sqlite/01__dump.sql @@ -36,4 +36,4 @@ INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(4,1,'memos/ INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(5,1,'memos/sponsor0000001','👍'); -- System Settings -INSERT INTO instance_setting VALUES ('MEMO_RELATED', '{"contentLengthLimit":8192,"enableAutoCompact":true,"enableComment":true,"enableLocation":true,"defaultVisibility":"PUBLIC","reactions":["👍","💛","🔥","👏","😂","👌","🚀","👀","🤔","🤡","❓","+1","🎉","💡","✅"]}', ''); +INSERT INTO system_setting VALUES ('MEMO_RELATED', '{"contentLengthLimit":8192,"enableAutoCompact":true,"enableComment":true,"enableLocation":true,"defaultVisibility":"PUBLIC","reactions":["👍","💛","🔥","👏","😂","👌","🚀","👀","🤔","🤡","❓","+1","🎉","💡","✅"]}', ''); From 14fb38f37560541bf2719647e7e8b1468937f8ef Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 7 Jan 2026 20:44:21 +0800 Subject: [PATCH 08/86] fix: attachment table name --- store/migration/mysql/LATEST.sql | 4 ++-- store/migration/postgres/LATEST.sql | 4 ++-- store/migration/sqlite/LATEST.sql | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/store/migration/mysql/LATEST.sql b/store/migration/mysql/LATEST.sql index adc86a9eb..a11108ade 100644 --- a/store/migration/mysql/LATEST.sql +++ b/store/migration/mysql/LATEST.sql @@ -58,8 +58,8 @@ CREATE TABLE `memo_relation` ( UNIQUE(`memo_id`,`related_memo_id`,`type`) ); --- resource -CREATE TABLE `resource` ( +-- attachment +CREATE TABLE `attachment` ( `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, `uid` VARCHAR(256) NOT NULL UNIQUE, `creator_id` INT NOT NULL, diff --git a/store/migration/postgres/LATEST.sql b/store/migration/postgres/LATEST.sql index b5b70a9ec..6f04c4fe7 100644 --- a/store/migration/postgres/LATEST.sql +++ b/store/migration/postgres/LATEST.sql @@ -58,8 +58,8 @@ CREATE TABLE memo_relation ( UNIQUE(memo_id, related_memo_id, type) ); --- resource -CREATE TABLE resource ( +-- attachment +CREATE TABLE attachment ( id SERIAL PRIMARY KEY, uid TEXT NOT NULL UNIQUE, creator_id INTEGER NOT NULL, diff --git a/store/migration/sqlite/LATEST.sql b/store/migration/sqlite/LATEST.sql index 6a36e9338..7aceda3ea 100644 --- a/store/migration/sqlite/LATEST.sql +++ b/store/migration/sqlite/LATEST.sql @@ -63,8 +63,8 @@ CREATE TABLE memo_relation ( UNIQUE(memo_id, related_memo_id, type) ); --- resource -CREATE TABLE resource ( +-- attachment +CREATE TABLE attachment ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT NOT NULL UNIQUE, creator_id INTEGER NOT NULL, @@ -80,9 +80,9 @@ CREATE TABLE resource ( payload TEXT NOT NULL DEFAULT '{}' ); -CREATE INDEX idx_resource_creator_id ON resource (creator_id); +CREATE INDEX idx_attachment_creator_id ON attachment (creator_id); -CREATE INDEX idx_resource_memo_id ON resource (memo_id); +CREATE INDEX idx_attachment_memo_id ON attachment (memo_id); -- activity CREATE TABLE activity ( From 7c3fcc297d8e5a955d9c0bc4f3ca917854132e8e Mon Sep 17 00:00:00 2001 From: Faizaan pochi <61882064+Faizaanp@users.noreply.github.com> Date: Wed, 7 Jan 2026 18:22:04 +0530 Subject: [PATCH 09/86] fix: allow public memo API access without authentication (#5451) --- server/router/api/v1/v1.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/router/api/v1/v1.go b/server/router/api/v1/v1.go index 74b342fa2..834f054fb 100644 --- a/server/router/api/v1/v1.go +++ b/server/router/api/v1/v1.go @@ -59,7 +59,7 @@ func (s *APIV1Service) RegisterGateway(ctx context.Context, echoServer *echo.Ech ctx := r.Context() // Get the RPC method name from context (set by grpc-gateway after routing) - rpcMethod, _ := runtime.RPCMethod(ctx) + rpcMethod, ok := runtime.RPCMethod(ctx) // Extract credentials from HTTP headers authHeader := r.Header.Get("Authorization") @@ -67,7 +67,8 @@ func (s *APIV1Service) RegisterGateway(ctx context.Context, echoServer *echo.Ech result := authenticator.Authenticate(ctx, authHeader) // Enforce authentication for non-public methods - if result == nil && !IsPublicMethod(rpcMethod) { + // If rpcMethod cannot be determined, allow through, service layer will handle visibility checks + if result == nil && ok && !IsPublicMethod(rpcMethod) { http.Error(w, `{"code": 16, "message": "authentication required"}`, http.StatusUnauthorized) return } From 013ea5251900840ab0fdba758cbdc2a06b86aa4e Mon Sep 17 00:00:00 2001 From: Om vataliya <109670967+Omcodes23@users.noreply.github.com> Date: Wed, 7 Jan 2026 19:28:47 +0530 Subject: [PATCH 10/86] fix: apply theme and locale changes immediately on login screen (#5440) (#5442) --- web/src/components/LocaleSelect.tsx | 5 ++++- web/src/components/ThemeSelect.tsx | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/web/src/components/LocaleSelect.tsx b/web/src/components/LocaleSelect.tsx index a5aa48c8e..55b52b214 100644 --- a/web/src/components/LocaleSelect.tsx +++ b/web/src/components/LocaleSelect.tsx @@ -2,7 +2,7 @@ import { GlobeIcon } from "lucide-react"; import { FC } from "react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { locales } from "@/i18n"; -import { getLocaleDisplayName } from "@/utils/i18n"; +import { getLocaleDisplayName, loadLocale } from "@/utils/i18n"; interface Props { value: Locale; @@ -13,6 +13,9 @@ const LocaleSelect: FC = (props: Props) => { const { onChange, value } = props; const handleSelectChange = async (locale: Locale) => { + // Apply locale globally immediately + loadLocale(locale); + // Also notify parent component onChange(locale); }; diff --git a/web/src/components/ThemeSelect.tsx b/web/src/components/ThemeSelect.tsx index af045b6a7..7b9b4bbf1 100644 --- a/web/src/components/ThemeSelect.tsx +++ b/web/src/components/ThemeSelect.tsx @@ -1,6 +1,6 @@ import { Monitor, Moon, MoonStar, Palette, Sun, Wallpaper } from "lucide-react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { THEME_OPTIONS } from "@/utils/theme"; +import { loadTheme, THEME_OPTIONS } from "@/utils/theme"; interface ThemeSelectProps { value?: string; @@ -21,6 +21,9 @@ const ThemeSelect = ({ value, onValueChange, className }: ThemeSelectProps = {}) const currentTheme = value || "system"; const handleThemeChange = (newTheme: string) => { + // Apply theme globally immediately + loadTheme(newTheme); + // Also notify parent component if callback is provided if (onValueChange) { onValueChange(newTheme); } From f4cf2e955996a2b3ae3b5c8b4dc59839044cbe54 Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 7 Jan 2026 22:40:45 +0800 Subject: [PATCH 11/86] test: improve migration tests stability and maintainability - Fix linting issues and address testcontainers deprecation in store/test/containers.go - Extract MemosStartupWaitStrategy for consistent container health checks - Refactor migrator_test.go to use a consolidated testMigration helper, reducing duplication - Add store/test/Dockerfile for optimized local test image builds --- go.mod | 2 +- store/test/Dockerfile | 13 ++ store/test/containers.go | 242 +++++++++++++++++++++++++++++++++++- store/test/migrator_test.go | 120 ++++++++++++++++++ 4 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 store/test/Dockerfile diff --git a/go.mod b/go.mod index cd7f5b5e1..fc85404e7 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.18.16 github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.19.4 github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 + github.com/docker/docker v28.5.1+incompatible github.com/go-sql-driver/mysql v1.9.3 github.com/google/cel-go v0.26.1 github.com/google/uuid v1.6.0 @@ -50,7 +51,6 @@ require ( github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/docker v28.5.1+incompatible // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect diff --git a/store/test/Dockerfile b/store/test/Dockerfile new file mode 100644 index 000000000..6b25a7079 --- /dev/null +++ b/store/test/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.25-alpine AS backend +WORKDIR /backend-build +COPY . . +RUN go build -o memos ./cmd/memos + +FROM alpine:latest +WORKDIR /usr/local/memos +COPY --from=backend /backend-build/memos /usr/local/memos/ +EXPOSE 5230 +RUN mkdir -p /var/opt/memos +ENV MEMOS_MODE="prod" +ENV MEMOS_PORT="5230" +ENTRYPOINT ["./memos"] diff --git a/store/test/containers.go b/store/test/containers.go index bd65ea40e..c5e139306 100644 --- a/store/test/containers.go +++ b/store/test/containers.go @@ -10,10 +10,12 @@ import ( "testing" "time" + "github.com/docker/docker/api/types/container" "github.com/pkg/errors" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/mysql" "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/network" "github.com/testcontainers/testcontainers-go/wait" // Database drivers for connection verification. @@ -24,9 +26,21 @@ import ( const ( testUser = "root" testPassword = "test" + + // Memos container settings for migration testing. + MemosDockerImage = "neosmemo/memos" + StableMemosVersion = "stable" ) var ( + // MemosStartupWaitStrategy defines the wait strategy for Memos container startup. + // It waits for the "started" log message (compatible with both old and new versions) + // and checks if port 5230 is listening. + MemosStartupWaitStrategy = wait.ForAll( + wait.ForLog("started"), + wait.ForListeningPort("5230/tcp"), + ).WithDeadline(180 * time.Second) + mysqlContainer *mysql.MySQLContainer postgresContainer *postgres.PostgresContainer mysqlOnce sync.Once @@ -34,13 +48,36 @@ var ( mysqlBaseDSN string postgresBaseDSN string dbCounter atomic.Int64 + + // Network for container communication. + testDockerNetwork *testcontainers.DockerNetwork + testNetworkOnce sync.Once ) +// getTestNetwork creates or returns the shared Docker network for container communication. +func getTestNetwork(ctx context.Context) (*testcontainers.DockerNetwork, error) { + var networkErr error + testNetworkOnce.Do(func() { + nw, err := network.New(ctx, network.WithDriver("bridge")) + if err != nil { + networkErr = err + return + } + testDockerNetwork = nw + }) + return testDockerNetwork, networkErr +} + // GetMySQLDSN starts a MySQL container (if not already running) and creates a fresh database for this test. func GetMySQLDSN(t *testing.T) string { ctx := context.Background() mysqlOnce.Do(func() { + nw, err := getTestNetwork(ctx) + if err != nil { + t.Fatalf("failed to create test network: %v", err) + } + container, err := mysql.Run(ctx, "mysql:8", mysql.WithDatabase("init_db"), @@ -55,6 +92,7 @@ func GetMySQLDSN(t *testing.T) string { wait.ForListeningPort("3306/tcp"), ).WithDeadline(120*time.Second), ), + network.WithNetwork(nil, nw), ) if err != nil { t.Fatalf("failed to start MySQL container: %v", err) @@ -130,6 +168,11 @@ func GetPostgresDSN(t *testing.T) string { ctx := context.Background() postgresOnce.Do(func() { + nw, err := getTestNetwork(ctx) + if err != nil { + t.Fatalf("failed to create test network: %v", err) + } + container, err := postgres.Run(ctx, "postgres:18", postgres.WithDatabase("init_db"), @@ -141,6 +184,7 @@ func GetPostgresDSN(t *testing.T) string { wait.ForListeningPort("5432/tcp"), ).WithDeadline(120*time.Second), ), + network.WithNetwork(nil, nw), ) if err != nil { t.Fatalf("failed to start PostgreSQL container: %v", err) @@ -179,7 +223,106 @@ func GetPostgresDSN(t *testing.T) string { return strings.Replace(postgresBaseDSN, "/init_db?", "/"+dbName+"?", 1) } -// TerminateContainers cleans up all running containers. +// GetDedicatedMySQLDSN starts a dedicated MySQL container for migration testing. +// This is needed because older Memos versions have bugs when connecting to a MySQL +// server that has other initialized databases (they incorrectly query migration_history +// on a fresh database without checking if the DB is initialized). +// Returns: DSN for host access, container hostname for internal network access, cleanup function. +func GetDedicatedMySQLDSN(t *testing.T) (dsn string, containerHost string, cleanup func()) { + ctx := context.Background() + + nw, err := getTestNetwork(ctx) + if err != nil { + t.Fatalf("failed to create test network: %v", err) + } + + container, err := mysql.Run(ctx, + "mysql:8", + mysql.WithDatabase("memos"), + mysql.WithUsername("root"), + mysql.WithPassword(testPassword), + testcontainers.WithEnv(map[string]string{ + "MYSQL_ROOT_PASSWORD": testPassword, + }), + testcontainers.WithWaitStrategy( + wait.ForAll( + wait.ForLog("ready for connections").WithOccurrence(2), + wait.ForListeningPort("3306/tcp"), + ).WithDeadline(120*time.Second), + ), + network.WithNetwork(nil, nw), + ) + if err != nil { + t.Fatalf("failed to start dedicated MySQL container: %v", err) + } + + hostDSN, err := container.ConnectionString(ctx, "multiStatements=true") + if err != nil { + container.Terminate(ctx) + t.Fatalf("failed to get MySQL connection string: %v", err) + } + + if err := waitForDB("mysql", hostDSN, 30*time.Second); err != nil { + container.Terminate(ctx) + t.Fatalf("MySQL not ready for connections: %v", err) + } + + name, _ := container.Name(ctx) + host := strings.TrimPrefix(name, "/") + + return hostDSN, host, func() { + container.Terminate(ctx) + } +} + +// GetDedicatedPostgresDSN starts a dedicated PostgreSQL container for migration testing. +// This is needed for isolation when testing migrations with older Memos versions. +// Returns: DSN for host access, container hostname for internal network access, cleanup function. +func GetDedicatedPostgresDSN(t *testing.T) (dsn string, containerHost string, cleanup func()) { + ctx := context.Background() + + nw, err := getTestNetwork(ctx) + if err != nil { + t.Fatalf("failed to create test network: %v", err) + } + + container, err := postgres.Run(ctx, + "postgres:18", + postgres.WithDatabase("memos"), + postgres.WithUsername(testUser), + postgres.WithPassword(testPassword), + testcontainers.WithWaitStrategy( + wait.ForAll( + wait.ForLog("database system is ready to accept connections").WithOccurrence(2), + wait.ForListeningPort("5432/tcp"), + ).WithDeadline(120*time.Second), + ), + network.WithNetwork(nil, nw), + ) + if err != nil { + t.Fatalf("failed to start dedicated PostgreSQL container: %v", err) + } + + hostDSN, err := container.ConnectionString(ctx, "sslmode=disable") + if err != nil { + container.Terminate(ctx) + t.Fatalf("failed to get PostgreSQL connection string: %v", err) + } + + if err := waitForDB("postgres", hostDSN, 30*time.Second); err != nil { + container.Terminate(ctx) + t.Fatalf("PostgreSQL not ready for connections: %v", err) + } + + name, _ := container.Name(ctx) + host := strings.TrimPrefix(name, "/") + + return hostDSN, host, func() { + container.Terminate(ctx) + } +} + +// TerminateContainers cleans up all running containers and network. // This is typically called from TestMain. func TerminateContainers() { ctx := context.Background() @@ -189,4 +332,101 @@ func TerminateContainers() { if postgresContainer != nil { _ = postgresContainer.Terminate(ctx) } + if testDockerNetwork != nil { + _ = testDockerNetwork.Remove(ctx) + } +} + +// GetMySQLContainerHost returns the MySQL container hostname for use within the Docker network. +func GetMySQLContainerHost() string { + if mysqlContainer == nil { + return "" + } + name, _ := mysqlContainer.Name(context.Background()) + // Remove leading slash from container name + return strings.TrimPrefix(name, "/") +} + +// GetPostgresContainerHost returns the PostgreSQL container hostname for use within the Docker network. +func GetPostgresContainerHost() string { + if postgresContainer == nil { + return "" + } + name, _ := postgresContainer.Name(context.Background()) + return strings.TrimPrefix(name, "/") +} + +// MemosContainerConfig holds configuration for starting a Memos container. +type MemosContainerConfig struct { + Version string // Memos version tag (e.g., "0.25") + Driver string // Database driver: sqlite, mysql, postgres + DSN string // Database DSN (for mysql/postgres) + DataDir string // Host directory to mount for SQLite data +} + +// StartMemosContainer starts a Memos container for migration testing. +// For SQLite, it mounts the dataDir to /var/opt/memos. +// For MySQL/PostgreSQL, it connects to the provided DSN via the test network. +// If Version is "local", builds the image from the local Dockerfile. +func StartMemosContainer(ctx context.Context, cfg MemosContainerConfig) (testcontainers.Container, error) { + env := map[string]string{ + "MEMOS_MODE": "prod", + } + + var mounts []testcontainers.ContainerMount + var opts []testcontainers.ContainerCustomizer + + switch cfg.Driver { + case "sqlite": + env["MEMOS_DRIVER"] = "sqlite" + opts = append(opts, testcontainers.WithHostConfigModifier(func(hc *container.HostConfig) { + hc.Binds = append(hc.Binds, fmt.Sprintf("%s:%s", cfg.DataDir, "/var/opt/memos")) + })) + case "mysql": + env["MEMOS_DRIVER"] = "mysql" + env["MEMOS_DSN"] = cfg.DSN + opts = append(opts, network.WithNetwork(nil, testDockerNetwork)) + case "postgres": + env["MEMOS_DRIVER"] = "postgres" + env["MEMOS_DSN"] = cfg.DSN + opts = append(opts, network.WithNetwork(nil, testDockerNetwork)) + default: + return nil, errors.Errorf("unsupported driver: %s", cfg.Driver) + } + + req := testcontainers.ContainerRequest{ + Env: env, + Mounts: testcontainers.Mounts(mounts...), + ExposedPorts: []string{"5230/tcp"}, + WaitingFor: MemosStartupWaitStrategy, + } + + // Use local Dockerfile build or remote image + if cfg.Version == "local" { + req.FromDockerfile = testcontainers.FromDockerfile{ + Context: "../../", + Dockerfile: "store/test/Dockerfile", // Simple Dockerfile without BuildKit requirements + } + } else { + req.Image = fmt.Sprintf("%s:%s", MemosDockerImage, cfg.Version) + } + + genericReq := testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + } + + // Apply network options + for _, opt := range opts { + if err := opt.Customize(&genericReq); err != nil { + return nil, errors.Wrap(err, "failed to apply container option") + } + } + + container, err := testcontainers.GenericContainer(ctx, genericReq) + if err != nil { + return nil, errors.Wrap(err, "failed to start memos container") + } + + return container, nil } diff --git a/store/test/migrator_test.go b/store/test/migrator_test.go index 946d9ce10..87bcb18ee 100644 --- a/store/test/migrator_test.go +++ b/store/test/migrator_test.go @@ -2,7 +2,10 @@ package test import ( "context" + "fmt" + "strings" "testing" + "time" "github.com/stretchr/testify/require" ) @@ -15,3 +18,120 @@ func TestGetCurrentSchemaVersion(t *testing.T) { require.NoError(t, err) require.Equal(t, "0.26.1", currentSchemaVersion) } + +// TestFreshInstall verifies that LATEST.sql applies correctly on a fresh database. +// This is essentially what NewTestingStore already does, but we make it explicit. +func TestFreshInstall(t *testing.T) { + ctx := context.Background() + + // NewTestingStore creates a fresh database and runs Migrate() + // which applies LATEST.sql for uninitialized databases + ts := NewTestingStore(ctx, t) + + // Verify migration completed successfully + currentSchemaVersion, err := ts.GetCurrentSchemaVersion() + require.NoError(t, err) + require.NotEmpty(t, currentSchemaVersion, "schema version should be set after fresh install") + + // Verify we can read instance settings (basic sanity check) + instanceSetting, err := ts.GetInstanceBasicSetting(ctx) + require.NoError(t, err) + require.Equal(t, currentSchemaVersion, instanceSetting.SchemaVersion) +} + +// testMigration is a helper function that orchestrates the migration test flow. +// It starts the stable version, waits for initialization, and then starts the local version. +func testMigration(t *testing.T, driver string, prepareFunc func() (MemosContainerConfig, func())) { + if getDriverFromEnv() != driver { + t.Skipf("skipping %s migration test for non-%s driver", driver, driver) + } + + ctx := context.Background() + + // Prepare resources (temp dir or dedicated container) + cfg, cleanup := prepareFunc() + if cleanup != nil { + defer cleanup() + } + + // 1. Start Old Memos container (Stable) + cfg.Version = StableMemosVersion + t.Logf("Starting Memos %s container to initialize %s database...", cfg.Version, driver) + oldContainer, err := StartMemosContainer(ctx, cfg) + require.NoError(t, err, "failed to start old memos container") + + // Wait for database to be fully initialized + time.Sleep(5 * time.Second) + + // Stop the old container + err = oldContainer.Terminate(ctx) + require.NoError(t, err, "failed to stop old memos container") + + t.Log("Old Memos container stopped, starting new container with local build...") + + // 2. Start New Memos container (Local) + cfg.Version = "local" // Triggers local build in StartMemosContainer + newContainer, err := StartMemosContainer(ctx, cfg) + require.NoError(t, err, "failed to start new memos container - migration may have failed") + defer newContainer.Terminate(ctx) + + t.Logf("Migration successful: %s -> local build", StableMemosVersion) +} + +// TestMigrationFromPreviousVersion_SQLite verifies that migrating from the previous +// Memos version to the current version works correctly for SQLite. +func TestMigrationFromPreviousVersion_SQLite(t *testing.T) { + testMigration(t, "sqlite", func() (MemosContainerConfig, func()) { + // Create a temp directory for SQLite data that persists across container restarts + dataDir := t.TempDir() + return MemosContainerConfig{ + Driver: "sqlite", + DataDir: dataDir, + }, nil + }) +} + +// TestMigrationFromPreviousVersion_MySQL verifies that migrating from the previous +// Memos version to the current version works correctly for MySQL. +func TestMigrationFromPreviousVersion_MySQL(t *testing.T) { + testMigration(t, "mysql", func() (MemosContainerConfig, func()) { + // For migration testing, we need a dedicated MySQL container + dsn, containerHost, cleanup := GetDedicatedMySQLDSN(t) + + // Extract database name from DSN + parts := strings.Split(dsn, "/") + dbNameWithParams := parts[len(parts)-1] + dbName := strings.Split(dbNameWithParams, "?")[0] + + // Container DSN uses internal network hostname + containerDSN := fmt.Sprintf("%s:%s@tcp(%s:3306)/%s", testUser, testPassword, containerHost, dbName) + + return MemosContainerConfig{ + Driver: "mysql", + DSN: containerDSN, + }, cleanup + }) +} + +// TestMigrationFromPreviousVersion_Postgres verifies that migrating from the previous +// Memos version to the current version works correctly for PostgreSQL. +func TestMigrationFromPreviousVersion_Postgres(t *testing.T) { + testMigration(t, "postgres", func() (MemosContainerConfig, func()) { + // For migration testing, we need a dedicated PostgreSQL container + dsn, containerHost, cleanup := GetDedicatedPostgresDSN(t) + + // Extract database name from DSN + parts := strings.Split(dsn, "/") + dbNameWithParams := parts[len(parts)-1] + dbName := strings.Split(dbNameWithParams, "?")[0] + + // Container DSN uses internal network hostname + containerDSN := fmt.Sprintf("postgres://%s:%s@%s:5432/%s?sslmode=disable", + testUser, testPassword, containerHost, dbName) + + return MemosContainerConfig{ + Driver: "postgres", + DSN: containerDSN, + }, cleanup + }) +} From 1985205dc2d0d931c8ab062855cb377fcc7efbd9 Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 8 Jan 2026 22:28:13 +0800 Subject: [PATCH 12/86] chore: remove memo_organizer table --- store/migration/mysql/0.26/01__drop_memo_organizer.sql | 1 + store/migration/mysql/LATEST.sql | 8 -------- store/migration/postgres/0.26/01__drop_memo_organizer.sql | 1 + store/migration/postgres/LATEST.sql | 8 -------- store/migration/sqlite/0.26/01__drop_memo_organizer.sql | 1 + store/migration/sqlite/LATEST.sql | 8 -------- store/test/migrator_test.go | 2 +- 7 files changed, 4 insertions(+), 25 deletions(-) create mode 100644 store/migration/mysql/0.26/01__drop_memo_organizer.sql create mode 100644 store/migration/postgres/0.26/01__drop_memo_organizer.sql create mode 100644 store/migration/sqlite/0.26/01__drop_memo_organizer.sql diff --git a/store/migration/mysql/0.26/01__drop_memo_organizer.sql b/store/migration/mysql/0.26/01__drop_memo_organizer.sql new file mode 100644 index 000000000..17c3579f2 --- /dev/null +++ b/store/migration/mysql/0.26/01__drop_memo_organizer.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS memo_organizer; diff --git a/store/migration/mysql/LATEST.sql b/store/migration/mysql/LATEST.sql index a11108ade..a76d7111b 100644 --- a/store/migration/mysql/LATEST.sql +++ b/store/migration/mysql/LATEST.sql @@ -42,14 +42,6 @@ CREATE TABLE `memo` ( `payload` JSON NOT NULL ); --- memo_organizer -CREATE TABLE `memo_organizer` ( - `memo_id` INT NOT NULL, - `user_id` INT NOT NULL, - `pinned` INT NOT NULL DEFAULT '0', - UNIQUE(`memo_id`,`user_id`) -); - -- memo_relation CREATE TABLE `memo_relation` ( `memo_id` INT NOT NULL, diff --git a/store/migration/postgres/0.26/01__drop_memo_organizer.sql b/store/migration/postgres/0.26/01__drop_memo_organizer.sql new file mode 100644 index 000000000..17c3579f2 --- /dev/null +++ b/store/migration/postgres/0.26/01__drop_memo_organizer.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS memo_organizer; diff --git a/store/migration/postgres/LATEST.sql b/store/migration/postgres/LATEST.sql index 6f04c4fe7..cbde126cd 100644 --- a/store/migration/postgres/LATEST.sql +++ b/store/migration/postgres/LATEST.sql @@ -42,14 +42,6 @@ CREATE TABLE memo ( payload JSONB NOT NULL DEFAULT '{}' ); --- memo_organizer -CREATE TABLE memo_organizer ( - memo_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - pinned INTEGER NOT NULL DEFAULT 0, - UNIQUE(memo_id, user_id) -); - -- memo_relation CREATE TABLE memo_relation ( memo_id INTEGER NOT NULL, diff --git a/store/migration/sqlite/0.26/01__drop_memo_organizer.sql b/store/migration/sqlite/0.26/01__drop_memo_organizer.sql new file mode 100644 index 000000000..17c3579f2 --- /dev/null +++ b/store/migration/sqlite/0.26/01__drop_memo_organizer.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS memo_organizer; diff --git a/store/migration/sqlite/LATEST.sql b/store/migration/sqlite/LATEST.sql index 7aceda3ea..590027714 100644 --- a/store/migration/sqlite/LATEST.sql +++ b/store/migration/sqlite/LATEST.sql @@ -47,14 +47,6 @@ CREATE TABLE memo ( CREATE INDEX idx_memo_creator_id ON memo (creator_id); --- memo_organizer -CREATE TABLE memo_organizer ( - memo_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - pinned INTEGER NOT NULL CHECK (pinned IN (0, 1)) DEFAULT 0, - UNIQUE(memo_id, user_id) -); - -- memo_relation CREATE TABLE memo_relation ( memo_id INTEGER NOT NULL, diff --git a/store/test/migrator_test.go b/store/test/migrator_test.go index 87bcb18ee..b680f6185 100644 --- a/store/test/migrator_test.go +++ b/store/test/migrator_test.go @@ -16,7 +16,7 @@ func TestGetCurrentSchemaVersion(t *testing.T) { currentSchemaVersion, err := ts.GetCurrentSchemaVersion() require.NoError(t, err) - require.Equal(t, "0.26.1", currentSchemaVersion) + require.Equal(t, "0.26.2", currentSchemaVersion) } // TestFreshInstall verifies that LATEST.sql applies correctly on a fresh database. From af6a0726d84c58ae86286fa6dc50f6862aa89088 Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 8 Jan 2026 22:53:46 +0800 Subject: [PATCH 13/86] chore: drop redundant indexes in sqlite migration --- store/migration/sqlite/0.26/02__drop_indexes.sql | 4 ++++ store/migration/sqlite/LATEST.sql | 8 -------- 2 files changed, 4 insertions(+), 8 deletions(-) create mode 100644 store/migration/sqlite/0.26/02__drop_indexes.sql diff --git a/store/migration/sqlite/0.26/02__drop_indexes.sql b/store/migration/sqlite/0.26/02__drop_indexes.sql new file mode 100644 index 000000000..2923ba4fa --- /dev/null +++ b/store/migration/sqlite/0.26/02__drop_indexes.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_user_username; +DROP INDEX IF EXISTS idx_memo_creator_id; +DROP INDEX IF EXISTS idx_attachment_creator_id; +DROP INDEX IF EXISTS idx_attachment_memo_id; diff --git a/store/migration/sqlite/LATEST.sql b/store/migration/sqlite/LATEST.sql index 590027714..130b0404f 100644 --- a/store/migration/sqlite/LATEST.sql +++ b/store/migration/sqlite/LATEST.sql @@ -21,8 +21,6 @@ CREATE TABLE user ( description TEXT NOT NULL DEFAULT '' ); -CREATE INDEX idx_user_username ON user (username); - -- user_setting CREATE TABLE user_setting ( user_id INTEGER NOT NULL, @@ -45,8 +43,6 @@ CREATE TABLE memo ( payload TEXT NOT NULL DEFAULT '{}' ); -CREATE INDEX idx_memo_creator_id ON memo (creator_id); - -- memo_relation CREATE TABLE memo_relation ( memo_id INTEGER NOT NULL, @@ -72,10 +68,6 @@ CREATE TABLE attachment ( payload TEXT NOT NULL DEFAULT '{}' ); -CREATE INDEX idx_attachment_creator_id ON attachment (creator_id); - -CREATE INDEX idx_attachment_memo_id ON attachment (memo_id); - -- activity CREATE TABLE activity ( id INTEGER PRIMARY KEY AUTOINCREMENT, From 1e8505bfeb066e490f6af17bc73e3e2a23297ff3 Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 8 Jan 2026 23:06:28 +0800 Subject: [PATCH 14/86] chore: fix tests --- store/test/migrator_test.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/store/test/migrator_test.go b/store/test/migrator_test.go index b680f6185..54abd27bb 100644 --- a/store/test/migrator_test.go +++ b/store/test/migrator_test.go @@ -10,15 +10,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestGetCurrentSchemaVersion(t *testing.T) { - ctx := context.Background() - ts := NewTestingStore(ctx, t) - - currentSchemaVersion, err := ts.GetCurrentSchemaVersion() - require.NoError(t, err) - require.Equal(t, "0.26.2", currentSchemaVersion) -} - // TestFreshInstall verifies that LATEST.sql applies correctly on a fresh database. // This is essentially what NewTestingStore already does, but we make it explicit. func TestFreshInstall(t *testing.T) { From 07eac279d07952310930a736e3af8d00e8149c19 Mon Sep 17 00:00:00 2001 From: Jongho Hong Date: Fri, 9 Jan 2026 00:13:21 +0900 Subject: [PATCH 15/86] chore(i18n): add missing Korean translations (#5456) Signed-off-by: Jongho Hong Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- web/src/locales/ko.json | 53 ++++++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/web/src/locales/ko.json b/web/src/locales/ko.json index a435c955c..4ab76b1bf 100644 --- a/web/src/locales/ko.json +++ b/web/src/locales/ko.json @@ -18,6 +18,7 @@ "about": "정보", "add": "추가", "admin": "관리", + "all": "전체", "archive": "보관 처리", "archived": "보관 목록", "attachments": "첨부파일", @@ -25,6 +26,7 @@ "avatar": "아바타", "basic": "기본", "beta": "베타", + "calendar": "달력", "cancel": "취소", "change": "변경", "clear": "정리하기", @@ -55,6 +57,7 @@ "layout": "레이아웃", "learn-more": "더 보기", "link": "링크", + "map": "지도", "mark": "연결", "memo": "메모", "memos": "메모", @@ -92,6 +95,7 @@ "statistics": "통계", "tags": "태그", "title": "제목", + "today": "오늘", "tree-mode": "트리 모드", "type": "타입", "unpin": "고정 해제", @@ -119,7 +123,8 @@ "save": "저장", "no-changes-detected": "변경사항이 없습니다", "focus-mode": "집중 모드", - "exit-focus-mode": "집중 모드 종료" + "exit-focus-mode": "집중 모드 종료", + "slash-commands": "명령어를 보시려면 /를 입력하세요." }, "filters": { "has-code": "코드있음", @@ -128,7 +133,10 @@ }, "inbox": { "memo-comment": "{{user}}님이 {{memo}}에 댓글을 남겼습니다.", - "version-update": "새 버전 {{version}}이 출시되었습니다!" + "failed-to-load": "알림함 항목을 불러오지 못했습니다", + "unread": "읽지 않음", + "no-unread": "읽지 않은 알림이 없습니다", + "no-archived": "보관된 알림이 없습니다" }, "markdown": { "checkbox": "체크박스", @@ -155,9 +163,7 @@ "display-time": "표시 시간", "filters": "필터", "links": "링크", - "list": "목록", "load-more": "더보기", - "masonry": "메이슨리", "no-archived-memos": "보관처리된 메모가 없습니다.", "no-memos": "메모가 없습니다.", "order-by": "정렬 기준", @@ -173,7 +179,9 @@ "private": "나만 볼 수 있음", "protected": "멤버 전용", "public": "공개" - } + }, + "list": "목록", + "masonry": "메이슨리" }, "message": { "archived-successfully": "성공적으로 보관되었습니다", @@ -225,6 +233,10 @@ }, "delete-resource": "리소스 삭제", "delete-selected-resources": "선택된 리소스 삭제", + "delete-all-unused": "사용하지 않는 항목 모두 삭제", + "delete-all-unused-confirm": "사용하지 않는 모든 리소스를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + "delete-all-unused-success": "리소스가 성공적으로 삭제되었습니다", + "delete-all-unused-error": "사용하지 않는 리소스 삭제에 실패했습니다", "fetching-data": "불러오는 중…", "file-drag-drop-prompt": "업로드할 파일을 여기에 드래그하세요", "linked-amount": "연결된 수", @@ -244,7 +256,10 @@ "access-token-section": { "access-token-copied-to-clipboard": "액세스 토큰이 복사되었습니다", "access-token-deletion": "액세스 토큰 {{accessToken}}을 삭제하시겠습니까? 이 행동은 되돌릴 수 없습니다.", + "access-token-deletion-description": "이 작업은 되돌릴 수 없습니다. 이 토큰을 사용하는 서비스들은 새 토큰을 사용하도록 업데이트해야 합니다.", + "access-token-deleted": "액세스 토큰 `{{description}}`이 삭제되었습니다", "create-dialog": { + "access-token-created": "액세스 토큰 `{{description}}`이 생성되었습니다", "create-access-token": "액세스 토큰 생성", "created-at": "생성일", "description": "설명", @@ -277,10 +292,15 @@ "member-section": { "admin": "관리자", "archive-member": "멤버 보관처리", - "archive-warning": "멤버 {{username}} 의 계정을 보관처리하시겠습니까?", + "archive-warning": "멤버 {{username}}의 계정을 보관처리하시겠습니까?", + "archive-warning-description": "보관처리하면 해당 계정이 비활성화됩니다. 나중에 복구하거나 삭제할 수 있습니다.", + "archive-success": "{{username}}님이 성공적으로 보관처리되었습니다", + "restore-success": "{{username}}님이 성공적으로 복구되었습니다", "create-a-member": "새 멤버 등록", "delete-member": "멤버 계정 삭제", - "delete-warning": "멤버 {{username}} 의 계정을 완전히 삭제하시겠습니까? 이 행동은 되돌릴 수 없습니다", + "delete-warning": "멤버 {{username}}의 계정을 완전히 삭제하시겠습니까? 이 행동은 되돌릴 수 없습니다.", + "delete-warning-description": "이 작업은 되돌릴 수 없습니다.", + "delete-success": "{{username}}님이 성공적으로 삭제되었습니다", "user": "사용자" }, "memo-related": "메모", @@ -299,6 +319,10 @@ "default-memo-visibility": "메모 공개 범위 기본값", "theme": "테마" }, + "shortcut": { + "delete-confirm": "바로가기 `{{title}}`를 정말로 삭제하시겠습니까?", + "delete-success": "바로가기 `{{title}}`가 성공적으로 삭제되었습니다" + }, "sso": "SSO", "sso-section": { "authorization-endpoint": "인증 엔드포인트", @@ -392,11 +416,17 @@ "create-dialog": { "an-easy-to-remember-name": "기억하기 쉬운 이름", "create-webhook": "Webhook 생성", + "create-webhook-success": "Webhook `{{name}}`이 생성되었습니다", "edit-webhook": "Webhook 편집", - "payload-url": "Payload URL", + "payload-url": "페이로드 URL", "title": "제목", "url-example-post-receive": "https://example.com/postreceive" }, + "delete-dialog": { + "delete-webhook-description": "이 작업은 되돌릴 수 없습니다.", + "delete-webhook-title": "Webhook `{{name}}`을 정말로 삭제하시겠습니까?", + "delete-webhook-success": "Webhook `{{name}}`이 성공적으로 삭제되었습니다" + }, "no-webhooks-found": "Webhook이 없습니다.", "title": "Webhook", "url": "URL" @@ -418,6 +448,7 @@ "create-tags-guide": "`#태그`를 입력하여 태그를 만들 수 있습니다.", "delete-confirm": "이 태그를 삭제하시겠습니까? 관련된 모든 메모가 보관됩니다.", "delete-tag": "태그 삭제", + "delete-success": "태그가 성공적으로 삭제되었습니다", "new-name": "새 이름", "no-tag-found": "태그를 찾을 수 없습니다", "old-name": "이전 이름", @@ -429,6 +460,10 @@ }, "tooltip": { "link-memo": "메모 링크", - "select-location": "위치" + "markdown-menu": "Markdown", + "select-location": "위치", + "select-visibility": "공개 범위", + "tags": "태그", + "upload-attachment": "첨부파일 업로드" } } From da2dd80e2f4143bcf208b29513a0c75bda075361 Mon Sep 17 00:00:00 2001 From: Faizaan pochi <61882064+Faizaanp@users.noreply.github.com> Date: Thu, 8 Jan 2026 20:51:33 +0530 Subject: [PATCH 16/86] fix: return Unauthenticated instead of PermissionDenied on token expiration (#5454) --- server/router/api/v1/idp_service.go | 15 ++++++++++++--- server/router/api/v1/instance_service.go | 5 ++++- server/router/api/v1/memo_service.go | 2 +- server/router/api/v1/test/idp_service_test.go | 4 ++-- server/router/api/v1/user_service.go | 12 ++++++++++++ 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/server/router/api/v1/idp_service.go b/server/router/api/v1/idp_service.go index 2b48d2c10..e8f055c40 100644 --- a/server/router/api/v1/idp_service.go +++ b/server/router/api/v1/idp_service.go @@ -18,7 +18,10 @@ func (s *APIV1Service) CreateIdentityProvider(ctx context.Context, request *v1pb if err != nil { return nil, status.Errorf(codes.Internal, "failed to get user: %v", err) } - if currentUser == nil || currentUser.Role != store.RoleHost { + if currentUser == nil { + return nil, status.Errorf(codes.Unauthenticated, "user not authenticated") + } + if currentUser.Role != store.RoleHost { return nil, status.Errorf(codes.PermissionDenied, "permission denied") } @@ -84,7 +87,10 @@ func (s *APIV1Service) UpdateIdentityProvider(ctx context.Context, request *v1pb if err != nil { return nil, status.Errorf(codes.Internal, "failed to get user: %v", err) } - if currentUser == nil || currentUser.Role != store.RoleHost { + if currentUser == nil { + return nil, status.Errorf(codes.Unauthenticated, "user not authenticated") + } + if currentUser.Role != store.RoleHost { return nil, status.Errorf(codes.PermissionDenied, "permission denied") } @@ -125,7 +131,10 @@ func (s *APIV1Service) DeleteIdentityProvider(ctx context.Context, request *v1pb if err != nil { return nil, status.Errorf(codes.Internal, "failed to get user: %v", err) } - if currentUser == nil || currentUser.Role != store.RoleHost { + if currentUser == nil { + return nil, status.Errorf(codes.Unauthenticated, "user not authenticated") + } + if currentUser.Role != store.RoleHost { return nil, status.Errorf(codes.PermissionDenied, "permission denied") } diff --git a/server/router/api/v1/instance_service.go b/server/router/api/v1/instance_service.go index 82830112e..049f9f3b6 100644 --- a/server/router/api/v1/instance_service.go +++ b/server/router/api/v1/instance_service.go @@ -70,7 +70,10 @@ func (s *APIV1Service) GetInstanceSetting(ctx context.Context, request *v1pb.Get if err != nil { return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err) } - if user == nil || user.Role != store.RoleHost { + if user == nil { + return nil, status.Errorf(codes.Unauthenticated, "user not authenticated") + } + if user.Role != store.RoleHost { return nil, status.Errorf(codes.PermissionDenied, "permission denied") } } diff --git a/server/router/api/v1/memo_service.go b/server/router/api/v1/memo_service.go index f407b009e..db6dd141e 100644 --- a/server/router/api/v1/memo_service.go +++ b/server/router/api/v1/memo_service.go @@ -281,7 +281,7 @@ func (s *APIV1Service) GetMemo(ctx context.Context, request *v1pb.GetMemoRequest return nil, status.Errorf(codes.Internal, "failed to get user") } if user == nil { - return nil, status.Errorf(codes.PermissionDenied, "permission denied") + return nil, status.Errorf(codes.Unauthenticated, "user not authenticated") } if memo.Visibility == store.Private && memo.CreatorID != user.ID { return nil, status.Errorf(codes.PermissionDenied, "permission denied") diff --git a/server/router/api/v1/test/idp_service_test.go b/server/router/api/v1/test/idp_service_test.go index d60d42de0..302a2737e 100644 --- a/server/router/api/v1/test/idp_service_test.go +++ b/server/router/api/v1/test/idp_service_test.go @@ -97,7 +97,7 @@ func TestCreateIdentityProvider(t *testing.T) { _, err := ts.Service.CreateIdentityProvider(ctx, req) require.Error(t, err) - require.Contains(t, err.Error(), "permission denied") + require.Contains(t, err.Error(), "user not authenticated") }) } @@ -547,6 +547,6 @@ func TestIdentityProviderPermissions(t *testing.T) { _, err := ts.Service.CreateIdentityProvider(ctx, req) require.Error(t, err) - require.Contains(t, err.Error(), "permission denied") + require.Contains(t, err.Error(), "user not authenticated") }) } diff --git a/server/router/api/v1/user_service.go b/server/router/api/v1/user_service.go index 6260a9547..9ef0d277b 100644 --- a/server/router/api/v1/user_service.go +++ b/server/router/api/v1/user_service.go @@ -192,6 +192,9 @@ func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserR if err != nil { return nil, status.Errorf(codes.Internal, "failed to get user: %v", err) } + if currentUser == nil { + return nil, status.Errorf(codes.Unauthenticated, "user not authenticated") + } // Check permission. // Only allow admin or self to update user. if currentUser.ID != userID && currentUser.Role != store.RoleAdmin && currentUser.Role != store.RoleHost { @@ -1240,6 +1243,9 @@ func (s *APIV1Service) ListUserNotifications(ctx context.Context, request *v1pb. if err != nil { return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err) } + if currentUser == nil { + return nil, status.Errorf(codes.Unauthenticated, "user not authenticated") + } if currentUser.ID != userID { return nil, status.Errorf(codes.PermissionDenied, "permission denied") } @@ -1287,6 +1293,9 @@ func (s *APIV1Service) UpdateUserNotification(ctx context.Context, request *v1pb return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err) } + if currentUser == nil { + return nil, status.Errorf(codes.Unauthenticated, "user not authenticated") + } // Verify ownership before updating inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{ ID: ¬ificationID, @@ -1352,6 +1361,9 @@ func (s *APIV1Service) DeleteUserNotification(ctx context.Context, request *v1pb return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err) } + if currentUser == nil { + return nil, status.Errorf(codes.Unauthenticated, "user not authenticated") + } // Verify ownership before deletion inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{ ID: ¬ificationID, From 7053edae279ad03eed5f8180d0983039365afebb Mon Sep 17 00:00:00 2001 From: Johnny Date: Sun, 11 Jan 2026 22:35:12 +0800 Subject: [PATCH 17/86] fix: allow guests to view public memo comments Add ListMemoComments to public endpoints whitelist so unauthenticated users can see public comments. The service layer already filters comments by visibility (only PUBLIC for guests). Fixes #5471 --- server/router/api/v1/acl_config.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/router/api/v1/acl_config.go b/server/router/api/v1/acl_config.go index 4045a7f5c..9958900b2 100644 --- a/server/router/api/v1/acl_config.go +++ b/server/router/api/v1/acl_config.go @@ -29,8 +29,9 @@ var PublicMethods = map[string]struct{}{ "/memos.api.v1.IdentityProviderService/ListIdentityProviders": {}, // Memo Service - public memos (visibility filtering done in service layer) - "/memos.api.v1.MemoService/GetMemo": {}, - "/memos.api.v1.MemoService/ListMemos": {}, + "/memos.api.v1.MemoService/GetMemo": {}, + "/memos.api.v1.MemoService/ListMemos": {}, + "/memos.api.v1.MemoService/ListMemoComments": {}, } // IsPublicMethod checks if a procedure path is public (no authentication required). From 9a3451b9d12cd7783a4d736df7c3b8fe184de6ae Mon Sep 17 00:00:00 2001 From: Johnny Date: Sun, 11 Jan 2026 22:41:20 +0800 Subject: [PATCH 18/86] fix(editor): filter RelationList to only show referencing memos - Filter out COMMENT type relations, only show REFERENCE type - When editing a memo, only show relations where current memo is the source - Pass memoName through EditorMetadata to RelationList for filtering --- .../MemoEditor/components/EditorMetadata.tsx | 8 ++++++-- .../MemoEditor/components/RelationList.tsx | 16 +++++++++------- web/src/components/MemoEditor/index.tsx | 2 +- .../components/MemoEditor/types/components.ts | 4 +++- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/web/src/components/MemoEditor/components/EditorMetadata.tsx b/web/src/components/MemoEditor/components/EditorMetadata.tsx index dc9ba17bd..bee0bebfc 100644 --- a/web/src/components/MemoEditor/components/EditorMetadata.tsx +++ b/web/src/components/MemoEditor/components/EditorMetadata.tsx @@ -5,7 +5,7 @@ import AttachmentList from "./AttachmentList"; import LocationDisplay from "./LocationDisplay"; import RelationList from "./RelationList"; -export const EditorMetadata: FC = () => { +export const EditorMetadata: FC = ({ memoName }) => { const { state, actions, dispatch } = useEditorContext(); return ( @@ -17,7 +17,11 @@ export const EditorMetadata: FC = () => { onRemoveLocalFile={(previewUrl) => dispatch(actions.removeLocalFile(previewUrl))} /> - dispatch(actions.setMetadata({ relations }))} /> + dispatch(actions.setMetadata({ relations }))} + memoName={memoName} + /> {state.metadata.location && ( dispatch(actions.setMetadata({ location: undefined }))} /> diff --git a/web/src/components/MemoEditor/components/RelationList.tsx b/web/src/components/MemoEditor/components/RelationList.tsx index c7f4ef920..b66c3311e 100644 --- a/web/src/components/MemoEditor/components/RelationList.tsx +++ b/web/src/components/MemoEditor/components/RelationList.tsx @@ -5,12 +5,13 @@ import { useEffect, useState } from "react"; import RelationCard from "@/components/MemoView/components/metadata/RelationCard"; import { memoServiceClient } from "@/connect"; import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb"; -import { MemoRelation_Memo, MemoRelation_MemoSchema } from "@/types/proto/api/v1/memo_service_pb"; +import { MemoRelation_Memo, MemoRelation_MemoSchema, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb"; interface RelationListProps { relations: MemoRelation[]; onRelationsChange?: (relations: MemoRelation[]) => void; parentPage?: string; + memoName?: string; } const RelationItemCard: FC<{ @@ -37,12 +38,13 @@ const RelationItemCard: FC<{ ); }; -const RelationList: FC = ({ relations, onRelationsChange, parentPage }) => { +const RelationList: FC = ({ relations, onRelationsChange, parentPage, memoName }) => { + const referenceRelations = relations.filter((r) => r.type === MemoRelation_Type.REFERENCE && (!memoName || r.memo?.name === memoName)); const [fetchedMemos, setFetchedMemos] = useState>({}); useEffect(() => { (async () => { - const missingSnippetRelations = relations.filter((relation) => !relation.relatedMemo?.snippet && relation.relatedMemo?.name); + const missingSnippetRelations = referenceRelations.filter((relation) => !relation.relatedMemo?.snippet && relation.relatedMemo?.name); if (missingSnippetRelations.length > 0) { const requests = missingSnippetRelations.map(async (relation) => { const memo = await memoServiceClient.getMemo({ name: relation.relatedMemo!.name }); @@ -58,7 +60,7 @@ const RelationList: FC = ({ relations, onRelationsChange, par }); } })(); - }, [relations]); + }, [referenceRelations]); const handleDeleteRelation = (memoName: string) => { if (onRelationsChange) { @@ -66,7 +68,7 @@ const RelationList: FC = ({ relations, onRelationsChange, par } }; - if (relations.length === 0) { + if (referenceRelations.length === 0) { return null; } @@ -74,11 +76,11 @@ const RelationList: FC = ({ relations, onRelationsChange, par
- Relations ({relations.length}) + Relations ({referenceRelations.length})
- {relations.map((relation) => { + {referenceRelations.map((relation) => { const relatedMemo = relation.relatedMemo!; const memo = relatedMemo.snippet ? relatedMemo : fetchedMemos[relatedMemo.name] || relatedMemo; return handleDeleteRelation(memo.name)} parentPage={parentPage} />; diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index 2aaee3971..ec05a99d3 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -146,7 +146,7 @@ const MemoEditorImpl: React.FC = ({ {/* Metadata and toolbar grouped together at bottom */}
- +
diff --git a/web/src/components/MemoEditor/types/components.ts b/web/src/components/MemoEditor/types/components.ts index b86d0a008..13f91ff48 100644 --- a/web/src/components/MemoEditor/types/components.ts +++ b/web/src/components/MemoEditor/types/components.ts @@ -26,7 +26,9 @@ export interface EditorToolbarProps { memoName?: string; } -export interface EditorMetadataProps {} +export interface EditorMetadataProps { + memoName?: string; +} export interface FocusModeOverlayProps { isActive: boolean; From 8f9ff5634c40999d8cfb08797a3d72c92ed04632 Mon Sep 17 00:00:00 2001 From: Johnny Date: Mon, 12 Jan 2026 09:11:08 +0800 Subject: [PATCH 19/86] chore: remove redundant icon --- .../MemoEditor/components/AttachmentList.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/web/src/components/MemoEditor/components/AttachmentList.tsx b/web/src/components/MemoEditor/components/AttachmentList.tsx index 240bba8eb..4aa6d1716 100644 --- a/web/src/components/MemoEditor/components/AttachmentList.tsx +++ b/web/src/components/MemoEditor/components/AttachmentList.tsx @@ -1,4 +1,4 @@ -import { ChevronDownIcon, ChevronUpIcon, FileIcon, Loader2Icon, PaperclipIcon, XIcon } from "lucide-react"; +import { ChevronDownIcon, ChevronUpIcon, FileIcon, PaperclipIcon, XIcon } from "lucide-react"; import type { FC } from "react"; import { cn } from "@/lib/utils"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; @@ -21,7 +21,7 @@ const AttachmentItemCard: FC<{ canMoveUp?: boolean; canMoveDown?: boolean; }> = ({ item, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => { - const { category, filename, thumbnailUrl, mimeType, size, isLocal } = item; + const { category, filename, thumbnailUrl, mimeType, size } = item; const fileTypeLabel = getFileTypeLabel(mimeType); const fileSizeLabel = size ? formatFileSize(size) : undefined; @@ -41,12 +41,6 @@ const AttachmentItemCard: FC<{
- {isLocal && ( - <> - - - - )} {fileTypeLabel} {fileSizeLabel && ( <> From 31f634b71ab26eda260c58a6cd61d02e515268e7 Mon Sep 17 00:00:00 2001 From: Johnny Date: Mon, 12 Jan 2026 22:28:42 +0800 Subject: [PATCH 20/86] chore: add more tests --- store/db/postgres/inbox.go | 3 +- store/test/activity_test.go | 267 ++++++++++++++++ store/test/idp_test.go | 381 ++++++++++++++++++++++ store/test/inbox_test.go | 525 +++++++++++++++++++++++++++++++ store/test/memo_relation_test.go | 429 +++++++++++++++++++++++++ store/test/user_setting_test.go | 330 +++++++++++++++++++ 6 files changed, 1934 insertions(+), 1 deletion(-) diff --git a/store/db/postgres/inbox.go b/store/db/postgres/inbox.go index 7df32e287..93bee9913 100644 --- a/store/db/postgres/inbox.go +++ b/store/db/postgres/inbox.go @@ -53,7 +53,8 @@ func (d *DB) ListInboxes(ctx context.Context, find *store.FindInbox) ([]*store.I if find.MessageType != nil { // Filter by message type using PostgreSQL JSON extraction // Note: The type field in JSON is stored as string representation of the enum name - where, args = append(where, "message->>'type' = "+placeholder(len(args)+1)), append(args, find.MessageType.String()) + // Cast to JSONB since the column is TEXT + where, args = append(where, "message::JSONB->>'type' = "+placeholder(len(args)+1)), append(args, find.MessageType.String()) } query := "SELECT id, created_ts, sender_id, receiver_id, status, message FROM inbox WHERE " + strings.Join(where, " AND ") + " ORDER BY created_ts DESC" diff --git a/store/test/activity_test.go b/store/test/activity_test.go index 1328199e8..879856289 100644 --- a/store/test/activity_test.go +++ b/store/test/activity_test.go @@ -99,3 +99,270 @@ func TestActivityListMultiple(t *testing.T) { ts.Close() } + +func TestActivityListByType(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create activities with MEMO_COMMENT type + _, err = ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{}, + }) + require.NoError(t, err) + + _, err = ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{}, + }) + require.NoError(t, err) + + // List by type + activityType := store.ActivityTypeMemoComment + activities, err := ts.ListActivities(ctx, &store.FindActivity{Type: &activityType}) + require.NoError(t, err) + require.Len(t, activities, 2) + for _, activity := range activities { + require.Equal(t, store.ActivityTypeMemoComment, activity.Type) + } + + ts.Close() +} + +func TestActivityPayloadMemoComment(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create activity with MemoComment payload + memoID := int32(123) + relatedMemoID := int32(456) + activity, err := ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{ + MemoComment: &storepb.ActivityMemoCommentPayload{ + MemoId: memoID, + RelatedMemoId: relatedMemoID, + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, activity.Payload) + require.NotNil(t, activity.Payload.MemoComment) + require.Equal(t, memoID, activity.Payload.MemoComment.MemoId) + require.Equal(t, relatedMemoID, activity.Payload.MemoComment.RelatedMemoId) + + // Verify payload is preserved when listing + found, err := ts.GetActivity(ctx, &store.FindActivity{ID: &activity.ID}) + require.NoError(t, err) + require.NotNil(t, found.Payload.MemoComment) + require.Equal(t, memoID, found.Payload.MemoComment.MemoId) + require.Equal(t, relatedMemoID, found.Payload.MemoComment.RelatedMemoId) + + ts.Close() +} + +func TestActivityEmptyPayload(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create activity with empty payload + activity, err := ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{}, + }) + require.NoError(t, err) + require.NotNil(t, activity.Payload) + + // Verify empty payload is handled correctly + found, err := ts.GetActivity(ctx, &store.FindActivity{ID: &activity.ID}) + require.NoError(t, err) + require.NotNil(t, found.Payload) + require.Nil(t, found.Payload.MemoComment) + + ts.Close() +} + +func TestActivityLevel(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create activity with INFO level + activity, err := ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{}, + }) + require.NoError(t, err) + require.Equal(t, store.ActivityLevelInfo, activity.Level) + + // Verify level is preserved when listing + found, err := ts.GetActivity(ctx, &store.FindActivity{ID: &activity.ID}) + require.NoError(t, err) + require.Equal(t, store.ActivityLevelInfo, found.Level) + + ts.Close() +} + +func TestActivityCreatorID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user1, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + user2, err := createTestingUserWithRole(ctx, ts, "user2", store.RoleUser) + require.NoError(t, err) + + // Create activity for user1 + activity1, err := ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user1.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{}, + }) + require.NoError(t, err) + require.Equal(t, user1.ID, activity1.CreatorID) + + // Create activity for user2 + activity2, err := ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user2.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{}, + }) + require.NoError(t, err) + require.Equal(t, user2.ID, activity2.CreatorID) + + // List all and verify creator IDs + activities, err := ts.ListActivities(ctx, &store.FindActivity{}) + require.NoError(t, err) + require.Len(t, activities, 2) + + ts.Close() +} + +func TestActivityCreatedTs(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + activity, err := ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{}, + }) + require.NoError(t, err) + require.NotZero(t, activity.CreatedTs) + + // Verify timestamp is preserved when listing + found, err := ts.GetActivity(ctx, &store.FindActivity{ID: &activity.ID}) + require.NoError(t, err) + require.Equal(t, activity.CreatedTs, found.CreatedTs) + + ts.Close() +} + +func TestActivityListEmpty(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // List activities when none exist + activities, err := ts.ListActivities(ctx, &store.FindActivity{}) + require.NoError(t, err) + require.Len(t, activities, 0) + + ts.Close() +} + +func TestActivityListWithIDAndType(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + activity, err := ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{}, + }) + require.NoError(t, err) + + // List with both ID and Type filters + activityType := store.ActivityTypeMemoComment + activities, err := ts.ListActivities(ctx, &store.FindActivity{ + ID: &activity.ID, + Type: &activityType, + }) + require.NoError(t, err) + require.Len(t, activities, 1) + require.Equal(t, activity.ID, activities[0].ID) + + ts.Close() +} + +func TestActivityPayloadComplexMemoComment(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create a memo first to use its ID + memo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "test-memo-for-activity", + CreatorID: user.ID, + Content: "Test memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create comment memo + commentMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "comment-memo", + CreatorID: user.ID, + Content: "This is a comment", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create activity with real memo IDs + activity, err := ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{ + MemoComment: &storepb.ActivityMemoCommentPayload{ + MemoId: memo.ID, + RelatedMemoId: commentMemo.ID, + }, + }, + }) + require.NoError(t, err) + require.Equal(t, memo.ID, activity.Payload.MemoComment.MemoId) + require.Equal(t, commentMemo.ID, activity.Payload.MemoComment.RelatedMemoId) + + // Verify payload is preserved + found, err := ts.GetActivity(ctx, &store.FindActivity{ID: &activity.ID}) + require.NoError(t, err) + require.Equal(t, memo.ID, found.Payload.MemoComment.MemoId) + require.Equal(t, commentMemo.ID, found.Payload.MemoComment.RelatedMemoId) + + ts.Close() +} diff --git a/store/test/idp_test.go b/store/test/idp_test.go index 0522454f8..d80cf488b 100644 --- a/store/test/idp_test.go +++ b/store/test/idp_test.go @@ -58,3 +58,384 @@ func TestIdentityProviderStore(t *testing.T) { require.Equal(t, 0, len(idpList)) ts.Close() } + +func TestIdentityProviderGetByID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Create IDP + idp, err := ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("Test IDP")) + require.NoError(t, err) + + // Get by ID + found, err := ts.GetIdentityProvider(ctx, &store.FindIdentityProvider{ID: &idp.Id}) + require.NoError(t, err) + require.NotNil(t, found) + require.Equal(t, idp.Id, found.Id) + require.Equal(t, idp.Name, found.Name) + + // Get by non-existent ID + nonExistentID := int32(99999) + notFound, err := ts.GetIdentityProvider(ctx, &store.FindIdentityProvider{ID: &nonExistentID}) + require.NoError(t, err) + require.Nil(t, notFound) + + ts.Close() +} + +func TestIdentityProviderListMultiple(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Create multiple IDPs + _, err := ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("GitHub OAuth")) + require.NoError(t, err) + _, err = ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("Google OAuth")) + require.NoError(t, err) + _, err = ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("GitLab OAuth")) + require.NoError(t, err) + + // List all + idpList, err := ts.ListIdentityProviders(ctx, &store.FindIdentityProvider{}) + require.NoError(t, err) + require.Len(t, idpList, 3) + + ts.Close() +} + +func TestIdentityProviderListByID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Create multiple IDPs + idp1, err := ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("GitHub OAuth")) + require.NoError(t, err) + _, err = ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("Google OAuth")) + require.NoError(t, err) + + // List by specific ID + idpList, err := ts.ListIdentityProviders(ctx, &store.FindIdentityProvider{ID: &idp1.Id}) + require.NoError(t, err) + require.Len(t, idpList, 1) + require.Equal(t, "GitHub OAuth", idpList[0].Name) + + ts.Close() +} + +func TestIdentityProviderUpdateName(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + idp, err := ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("Original Name")) + require.NoError(t, err) + require.Equal(t, "Original Name", idp.Name) + + // Update name + newName := "Updated Name" + updated, err := ts.UpdateIdentityProvider(ctx, &store.UpdateIdentityProviderV1{ + ID: idp.Id, + Type: storepb.IdentityProvider_OAUTH2, + Name: &newName, + }) + require.NoError(t, err) + require.Equal(t, "Updated Name", updated.Name) + + // Verify update persisted + found, err := ts.GetIdentityProvider(ctx, &store.FindIdentityProvider{ID: &idp.Id}) + require.NoError(t, err) + require.Equal(t, "Updated Name", found.Name) + + ts.Close() +} + +func TestIdentityProviderUpdateIdentifierFilter(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + idp, err := ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("Test IDP")) + require.NoError(t, err) + require.Equal(t, "", idp.IdentifierFilter) + + // Update identifier filter + newFilter := "@example.com$" + updated, err := ts.UpdateIdentityProvider(ctx, &store.UpdateIdentityProviderV1{ + ID: idp.Id, + Type: storepb.IdentityProvider_OAUTH2, + IdentifierFilter: &newFilter, + }) + require.NoError(t, err) + require.Equal(t, "@example.com$", updated.IdentifierFilter) + + // Verify update persisted + found, err := ts.GetIdentityProvider(ctx, &store.FindIdentityProvider{ID: &idp.Id}) + require.NoError(t, err) + require.Equal(t, "@example.com$", found.IdentifierFilter) + + ts.Close() +} + +func TestIdentityProviderUpdateConfig(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + idp, err := ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("Test IDP")) + require.NoError(t, err) + + // Update config + newConfig := &storepb.IdentityProviderConfig{ + Config: &storepb.IdentityProviderConfig_Oauth2Config{ + Oauth2Config: &storepb.OAuth2Config{ + ClientId: "new_client_id", + ClientSecret: "new_client_secret", + AuthUrl: "https://newprovider.com/auth", + TokenUrl: "https://newprovider.com/token", + UserInfoUrl: "https://newprovider.com/user", + Scopes: []string{"openid", "profile", "email"}, + FieldMapping: &storepb.FieldMapping{ + Identifier: "sub", + DisplayName: "name", + Email: "email", + }, + }, + }, + } + updated, err := ts.UpdateIdentityProvider(ctx, &store.UpdateIdentityProviderV1{ + ID: idp.Id, + Type: storepb.IdentityProvider_OAUTH2, + Config: newConfig, + }) + require.NoError(t, err) + require.Equal(t, "new_client_id", updated.Config.GetOauth2Config().ClientId) + require.Equal(t, "new_client_secret", updated.Config.GetOauth2Config().ClientSecret) + require.Contains(t, updated.Config.GetOauth2Config().Scopes, "openid") + + // Verify update persisted + found, err := ts.GetIdentityProvider(ctx, &store.FindIdentityProvider{ID: &idp.Id}) + require.NoError(t, err) + require.Equal(t, "new_client_id", found.Config.GetOauth2Config().ClientId) + + ts.Close() +} + +func TestIdentityProviderUpdateMultipleFields(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + idp, err := ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("Original")) + require.NoError(t, err) + + // Update multiple fields at once + newName := "Updated IDP" + newFilter := "^admin@" + updated, err := ts.UpdateIdentityProvider(ctx, &store.UpdateIdentityProviderV1{ + ID: idp.Id, + Type: storepb.IdentityProvider_OAUTH2, + Name: &newName, + IdentifierFilter: &newFilter, + }) + require.NoError(t, err) + require.Equal(t, "Updated IDP", updated.Name) + require.Equal(t, "^admin@", updated.IdentifierFilter) + + ts.Close() +} + +func TestIdentityProviderDelete(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + idp, err := ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("Test IDP")) + require.NoError(t, err) + + // Delete + err = ts.DeleteIdentityProvider(ctx, &store.DeleteIdentityProvider{ID: idp.Id}) + require.NoError(t, err) + + // Verify deletion + found, err := ts.GetIdentityProvider(ctx, &store.FindIdentityProvider{ID: &idp.Id}) + require.NoError(t, err) + require.Nil(t, found) + + ts.Close() +} + +func TestIdentityProviderDeleteNotAffectOthers(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Create multiple IDPs + idp1, err := ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("IDP 1")) + require.NoError(t, err) + idp2, err := ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("IDP 2")) + require.NoError(t, err) + + // Delete first one + err = ts.DeleteIdentityProvider(ctx, &store.DeleteIdentityProvider{ID: idp1.Id}) + require.NoError(t, err) + + // Verify second still exists + found, err := ts.GetIdentityProvider(ctx, &store.FindIdentityProvider{ID: &idp2.Id}) + require.NoError(t, err) + require.NotNil(t, found) + require.Equal(t, "IDP 2", found.Name) + + // Verify list only contains second + idpList, err := ts.ListIdentityProviders(ctx, &store.FindIdentityProvider{}) + require.NoError(t, err) + require.Len(t, idpList, 1) + + ts.Close() +} + +func TestIdentityProviderOAuth2ConfigScopes(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Create IDP with multiple scopes + idp, err := ts.CreateIdentityProvider(ctx, &storepb.IdentityProvider{ + Name: "Multi-Scope OAuth", + Type: storepb.IdentityProvider_OAUTH2, + Config: &storepb.IdentityProviderConfig{ + Config: &storepb.IdentityProviderConfig_Oauth2Config{ + Oauth2Config: &storepb.OAuth2Config{ + ClientId: "client_id", + ClientSecret: "client_secret", + AuthUrl: "https://provider.com/auth", + TokenUrl: "https://provider.com/token", + UserInfoUrl: "https://provider.com/userinfo", + Scopes: []string{"openid", "profile", "email", "groups"}, + FieldMapping: &storepb.FieldMapping{ + Identifier: "sub", + DisplayName: "name", + Email: "email", + }, + }, + }, + }, + }) + require.NoError(t, err) + + // Verify scopes are preserved + found, err := ts.GetIdentityProvider(ctx, &store.FindIdentityProvider{ID: &idp.Id}) + require.NoError(t, err) + require.Len(t, found.Config.GetOauth2Config().Scopes, 4) + require.Contains(t, found.Config.GetOauth2Config().Scopes, "openid") + require.Contains(t, found.Config.GetOauth2Config().Scopes, "groups") + + ts.Close() +} + +func TestIdentityProviderFieldMapping(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Create IDP with custom field mapping + idp, err := ts.CreateIdentityProvider(ctx, &storepb.IdentityProvider{ + Name: "Custom Field Mapping", + Type: storepb.IdentityProvider_OAUTH2, + Config: &storepb.IdentityProviderConfig{ + Config: &storepb.IdentityProviderConfig_Oauth2Config{ + Oauth2Config: &storepb.OAuth2Config{ + ClientId: "client_id", + ClientSecret: "client_secret", + AuthUrl: "https://provider.com/auth", + TokenUrl: "https://provider.com/token", + UserInfoUrl: "https://provider.com/userinfo", + Scopes: []string{"login"}, + FieldMapping: &storepb.FieldMapping{ + Identifier: "preferred_username", + DisplayName: "full_name", + Email: "email_address", + }, + }, + }, + }, + }) + require.NoError(t, err) + + // Verify field mapping is preserved + found, err := ts.GetIdentityProvider(ctx, &store.FindIdentityProvider{ID: &idp.Id}) + require.NoError(t, err) + require.Equal(t, "preferred_username", found.Config.GetOauth2Config().FieldMapping.Identifier) + require.Equal(t, "full_name", found.Config.GetOauth2Config().FieldMapping.DisplayName) + require.Equal(t, "email_address", found.Config.GetOauth2Config().FieldMapping.Email) + + ts.Close() +} + +func TestIdentityProviderIdentifierFilterPatterns(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + testCases := []struct { + name string + filter string + }{ + {"Domain filter", "@company\\.com$"}, + {"Prefix filter", "^admin_"}, + {"Complex regex", "^[a-z]+@(dept1|dept2)\\.example\\.com$"}, + {"Empty filter", ""}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + idp, err := ts.CreateIdentityProvider(ctx, &storepb.IdentityProvider{ + Name: tc.name, + Type: storepb.IdentityProvider_OAUTH2, + IdentifierFilter: tc.filter, + Config: &storepb.IdentityProviderConfig{ + Config: &storepb.IdentityProviderConfig_Oauth2Config{ + Oauth2Config: &storepb.OAuth2Config{ + ClientId: "client_id", + ClientSecret: "client_secret", + AuthUrl: "https://provider.com/auth", + TokenUrl: "https://provider.com/token", + UserInfoUrl: "https://provider.com/userinfo", + Scopes: []string{"login"}, + FieldMapping: &storepb.FieldMapping{ + Identifier: "sub", + }, + }, + }, + }, + }) + require.NoError(t, err) + + found, err := ts.GetIdentityProvider(ctx, &store.FindIdentityProvider{ID: &idp.Id}) + require.NoError(t, err) + require.Equal(t, tc.filter, found.IdentifierFilter) + + // Cleanup + err = ts.DeleteIdentityProvider(ctx, &store.DeleteIdentityProvider{ID: idp.Id}) + require.NoError(t, err) + }) + } + + ts.Close() +} + +// Helper function to create a test OAuth2 IDP +func createTestOAuth2IDP(name string) *storepb.IdentityProvider { + return &storepb.IdentityProvider{ + Name: name, + Type: storepb.IdentityProvider_OAUTH2, + IdentifierFilter: "", + Config: &storepb.IdentityProviderConfig{ + Config: &storepb.IdentityProviderConfig_Oauth2Config{ + Oauth2Config: &storepb.OAuth2Config{ + ClientId: "client_id", + ClientSecret: "client_secret", + AuthUrl: "https://provider.com/auth", + TokenUrl: "https://provider.com/token", + UserInfoUrl: "https://provider.com/userinfo", + Scopes: []string{"login"}, + FieldMapping: &storepb.FieldMapping{ + Identifier: "login", + DisplayName: "name", + Email: "email", + }, + }, + }, + }, + } +} diff --git a/store/test/inbox_test.go b/store/test/inbox_test.go index 0c74bc104..e3205099d 100644 --- a/store/test/inbox_test.go +++ b/store/test/inbox_test.go @@ -52,3 +52,528 @@ func TestInboxStore(t *testing.T) { require.Equal(t, 0, len(inboxes)) ts.Close() } + +func TestInboxListByID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + inbox, err := ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + // List by ID + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{ID: &inbox.ID}) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, inbox.ID, inboxes[0].ID) + + // List by non-existent ID + nonExistentID := int32(99999) + inboxes, err = ts.ListInboxes(ctx, &store.FindInbox{ID: &nonExistentID}) + require.NoError(t, err) + require.Len(t, inboxes, 0) + + ts.Close() +} + +func TestInboxListBySenderID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user1, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + user2, err := createTestingUserWithRole(ctx, ts, "user2", store.RoleUser) + require.NoError(t, err) + + // Create inbox from system bot (senderID = 0) + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user1.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + // Create inbox from user2 + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: user2.ID, + ReceiverID: user1.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + // List by sender ID = user2 + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{SenderID: &user2.ID}) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, user2.ID, inboxes[0].SenderID) + + // List by sender ID = 0 (system bot) + systemBotID := int32(0) + inboxes, err = ts.ListInboxes(ctx, &store.FindInbox{SenderID: &systemBotID}) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, int32(0), inboxes[0].SenderID) + + ts.Close() +} + +func TestInboxListByStatus(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create UNREAD inbox + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + // Create another inbox and archive it + inbox2, err := ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + _, err = ts.UpdateInbox(ctx, &store.UpdateInbox{ID: inbox2.ID, Status: store.ARCHIVED}) + require.NoError(t, err) + + // List by UNREAD status + unreadStatus := store.UNREAD + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{Status: &unreadStatus}) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, store.UNREAD, inboxes[0].Status) + + // List by ARCHIVED status + archivedStatus := store.ARCHIVED + inboxes, err = ts.ListInboxes(ctx, &store.FindInbox{Status: &archivedStatus}) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, store.ARCHIVED, inboxes[0].Status) + + ts.Close() +} + +func TestInboxListByMessageType(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create MEMO_COMMENT inboxes + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + // List by MEMO_COMMENT type + memoCommentType := storepb.InboxMessage_MEMO_COMMENT + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{MessageType: &memoCommentType}) + require.NoError(t, err) + require.Len(t, inboxes, 2) + for _, inbox := range inboxes { + require.Equal(t, storepb.InboxMessage_MEMO_COMMENT, inbox.Message.Type) + } + + ts.Close() +} + +func TestInboxListPagination(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create 5 inboxes + for i := 0; i < 5; i++ { + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + } + + // Test Limit only + limit := 3 + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{ + ReceiverID: &user.ID, + Limit: &limit, + }) + require.NoError(t, err) + require.Len(t, inboxes, 3) + + // Test Limit + Offset (offset requires limit in the implementation) + limit = 2 + offset := 2 + inboxes, err = ts.ListInboxes(ctx, &store.FindInbox{ + ReceiverID: &user.ID, + Limit: &limit, + Offset: &offset, + }) + require.NoError(t, err) + require.Len(t, inboxes, 2) + + // Test Limit + Offset skipping to end + limit = 10 + offset = 3 + inboxes, err = ts.ListInboxes(ctx, &store.FindInbox{ + ReceiverID: &user.ID, + Limit: &limit, + Offset: &offset, + }) + require.NoError(t, err) + require.Len(t, inboxes, 2) // Only 2 remaining after offset of 3 + + ts.Close() +} + +func TestInboxListCombinedFilters(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user1, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + user2, err := createTestingUserWithRole(ctx, ts, "user2", store.RoleUser) + require.NoError(t, err) + + // Create various inboxes + // user2 -> user1, MEMO_COMMENT, UNREAD + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: user2.ID, + ReceiverID: user1.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + // user2 -> user1, TYPE_UNSPECIFIED, UNREAD + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: user2.ID, + ReceiverID: user1.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_TYPE_UNSPECIFIED}, + }) + require.NoError(t, err) + + // system -> user1, MEMO_COMMENT, ARCHIVED + inbox3, err := ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user1.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + _, err = ts.UpdateInbox(ctx, &store.UpdateInbox{ID: inbox3.ID, Status: store.ARCHIVED}) + require.NoError(t, err) + + // Combined filter: ReceiverID + SenderID + Status + unreadStatus := store.UNREAD + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{ + ReceiverID: &user1.ID, + SenderID: &user2.ID, + Status: &unreadStatus, + }) + require.NoError(t, err) + require.Len(t, inboxes, 2) + + // Combined filter: ReceiverID + MessageType + Status + memoCommentType := storepb.InboxMessage_MEMO_COMMENT + inboxes, err = ts.ListInboxes(ctx, &store.FindInbox{ + ReceiverID: &user1.ID, + MessageType: &memoCommentType, + Status: &unreadStatus, + }) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, user2.ID, inboxes[0].SenderID) + + ts.Close() +} + +func TestInboxMessagePayload(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create inbox with message payload containing activity ID + activityID := int32(123) + inbox, err := ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{ + Type: storepb.InboxMessage_MEMO_COMMENT, + ActivityId: &activityID, + }, + }) + require.NoError(t, err) + require.NotNil(t, inbox.Message) + require.Equal(t, storepb.InboxMessage_MEMO_COMMENT, inbox.Message.Type) + require.Equal(t, activityID, *inbox.Message.ActivityId) + + // List and verify payload is preserved + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{ReceiverID: &user.ID}) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, activityID, *inboxes[0].Message.ActivityId) + + ts.Close() +} + +func TestInboxUpdateStatus(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + inbox, err := ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + require.Equal(t, store.UNREAD, inbox.Status) + + // Update to ARCHIVED + updated, err := ts.UpdateInbox(ctx, &store.UpdateInbox{ + ID: inbox.ID, + Status: store.ARCHIVED, + }) + require.NoError(t, err) + require.Equal(t, store.ARCHIVED, updated.Status) + require.Equal(t, inbox.ID, updated.ID) + + // Verify the update persisted + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{ID: &inbox.ID}) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, store.ARCHIVED, inboxes[0].Status) + + ts.Close() +} + +func TestInboxListByMessageTypeMultipleTypes(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create inboxes with different message types + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_TYPE_UNSPECIFIED}, + }) + require.NoError(t, err) + + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + // Filter by MEMO_COMMENT - should get 2 + memoCommentType := storepb.InboxMessage_MEMO_COMMENT + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{ + ReceiverID: &user.ID, + MessageType: &memoCommentType, + }) + require.NoError(t, err) + require.Len(t, inboxes, 2) + for _, inbox := range inboxes { + require.Equal(t, storepb.InboxMessage_MEMO_COMMENT, inbox.Message.Type) + } + + // Filter by TYPE_UNSPECIFIED - should get 1 + unspecifiedType := storepb.InboxMessage_TYPE_UNSPECIFIED + inboxes, err = ts.ListInboxes(ctx, &store.FindInbox{ + ReceiverID: &user.ID, + MessageType: &unspecifiedType, + }) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, storepb.InboxMessage_TYPE_UNSPECIFIED, inboxes[0].Message.Type) + + ts.Close() +} + +func TestInboxMessageTypeFilterWithPayload(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create inbox with full payload + activityID := int32(456) + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{ + Type: storepb.InboxMessage_MEMO_COMMENT, + ActivityId: &activityID, + }, + }) + require.NoError(t, err) + + // Create inbox with different type but also has payload + otherActivityID := int32(789) + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{ + Type: storepb.InboxMessage_TYPE_UNSPECIFIED, + ActivityId: &otherActivityID, + }, + }) + require.NoError(t, err) + + // Filter by type should work correctly even with complex JSON payload + memoCommentType := storepb.InboxMessage_MEMO_COMMENT + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{ + ReceiverID: &user.ID, + MessageType: &memoCommentType, + }) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, activityID, *inboxes[0].Message.ActivityId) + + ts.Close() +} + +func TestInboxMessageTypeFilterWithStatusAndPagination(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create multiple inboxes with various combinations + for i := 0; i < 5; i++ { + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + } + + // Archive 2 of them + allInboxes, err := ts.ListInboxes(ctx, &store.FindInbox{ReceiverID: &user.ID}) + require.NoError(t, err) + for i := 0; i < 2; i++ { + _, err = ts.UpdateInbox(ctx, &store.UpdateInbox{ID: allInboxes[i].ID, Status: store.ARCHIVED}) + require.NoError(t, err) + } + + // Filter by type + status + pagination + memoCommentType := storepb.InboxMessage_MEMO_COMMENT + unreadStatus := store.UNREAD + limit := 2 + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{ + ReceiverID: &user.ID, + MessageType: &memoCommentType, + Status: &unreadStatus, + Limit: &limit, + }) + require.NoError(t, err) + require.Len(t, inboxes, 2) + for _, inbox := range inboxes { + require.Equal(t, storepb.InboxMessage_MEMO_COMMENT, inbox.Message.Type) + require.Equal(t, store.UNREAD, inbox.Status) + } + + // Get next page + offset := 2 + inboxes, err = ts.ListInboxes(ctx, &store.FindInbox{ + ReceiverID: &user.ID, + MessageType: &memoCommentType, + Status: &unreadStatus, + Limit: &limit, + Offset: &offset, + }) + require.NoError(t, err) + require.Len(t, inboxes, 1) // Only 1 remaining (3 unread total, got 2, now 1 left) + + ts.Close() +} + +func TestInboxMultipleReceivers(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user1, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + user2, err := createTestingUserWithRole(ctx, ts, "user2", store.RoleUser) + require.NoError(t, err) + + // Create inbox for user1 + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user1.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + // Create inbox for user2 + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user2.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + // User1 should only see their inbox + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{ReceiverID: &user1.ID}) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, user1.ID, inboxes[0].ReceiverID) + + // User2 should only see their inbox + inboxes, err = ts.ListInboxes(ctx, &store.FindInbox{ReceiverID: &user2.ID}) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, user2.ID, inboxes[0].ReceiverID) + + ts.Close() +} diff --git a/store/test/memo_relation_test.go b/store/test/memo_relation_test.go index dd05134ef..32ccaf0b1 100644 --- a/store/test/memo_relation_test.go +++ b/store/test/memo_relation_test.go @@ -239,3 +239,432 @@ func TestMemoRelationDifferentTypes(t *testing.T) { ts.Close() } + +func TestMemoRelationUpsertSameRelation(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + mainMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "main-memo", + CreatorID: user.ID, + Content: "main memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + relatedMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "related-memo", + CreatorID: user.ID, + Content: "related memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create relation + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: mainMemo.ID, + RelatedMemoID: relatedMemo.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + + // Upsert the same relation again (should not create duplicate) + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: mainMemo.ID, + RelatedMemoID: relatedMemo.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + + // Verify only one relation exists + relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &mainMemo.ID, + }) + require.NoError(t, err) + require.Len(t, relations, 1) + + ts.Close() +} + +func TestMemoRelationDeleteByType(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + mainMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "main-memo", + CreatorID: user.ID, + Content: "main memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + relatedMemo1, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "related-memo-1", + CreatorID: user.ID, + Content: "related memo 1 content", + Visibility: store.Public, + }) + require.NoError(t, err) + + relatedMemo2, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "related-memo-2", + CreatorID: user.ID, + Content: "related memo 2 content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create reference relations + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: mainMemo.ID, + RelatedMemoID: relatedMemo1.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + + // Create comment relation + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: mainMemo.ID, + RelatedMemoID: relatedMemo2.ID, + Type: store.MemoRelationComment, + }) + require.NoError(t, err) + + // Delete only reference type relations + refType := store.MemoRelationReference + err = ts.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{ + MemoID: &mainMemo.ID, + Type: &refType, + }) + require.NoError(t, err) + + // Verify only comment relation remains + relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &mainMemo.ID, + }) + require.NoError(t, err) + require.Len(t, relations, 1) + require.Equal(t, store.MemoRelationComment, relations[0].Type) + + ts.Close() +} + +func TestMemoRelationDeleteByMemoID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + memo1, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "memo-1", + CreatorID: user.ID, + Content: "memo 1 content", + Visibility: store.Public, + }) + require.NoError(t, err) + + memo2, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "memo-2", + CreatorID: user.ID, + Content: "memo 2 content", + Visibility: store.Public, + }) + require.NoError(t, err) + + relatedMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "related-memo", + CreatorID: user.ID, + Content: "related memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create relations for both memos + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: memo1.ID, + RelatedMemoID: relatedMemo.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: memo2.ID, + RelatedMemoID: relatedMemo.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + + // Delete all relations for memo1 + err = ts.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{ + MemoID: &memo1.ID, + }) + require.NoError(t, err) + + // Verify memo1's relations are gone + relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &memo1.ID, + }) + require.NoError(t, err) + require.Len(t, relations, 0) + + // Verify memo2's relations still exist + relations, err = ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &memo2.ID, + }) + require.NoError(t, err) + require.Len(t, relations, 1) + + ts.Close() +} + +func TestMemoRelationListByRelatedMemoID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create a memo that will be referenced by others + targetMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "target-memo", + CreatorID: user.ID, + Content: "target memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create memos that reference the target + referrer1, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "referrer-1", + CreatorID: user.ID, + Content: "referrer 1 content", + Visibility: store.Public, + }) + require.NoError(t, err) + + referrer2, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "referrer-2", + CreatorID: user.ID, + Content: "referrer 2 content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create relations pointing to target + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: referrer1.ID, + RelatedMemoID: targetMemo.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: referrer2.ID, + RelatedMemoID: targetMemo.ID, + Type: store.MemoRelationComment, + }) + require.NoError(t, err) + + // List by related memo ID (find all memos that reference the target) + relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + RelatedMemoID: &targetMemo.ID, + }) + require.NoError(t, err) + require.Len(t, relations, 2) + + ts.Close() +} + +func TestMemoRelationListCombinedFilters(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + mainMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "main-memo", + CreatorID: user.ID, + Content: "main memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + relatedMemo1, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "related-memo-1", + CreatorID: user.ID, + Content: "related memo 1 content", + Visibility: store.Public, + }) + require.NoError(t, err) + + relatedMemo2, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "related-memo-2", + CreatorID: user.ID, + Content: "related memo 2 content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create multiple relations + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: mainMemo.ID, + RelatedMemoID: relatedMemo1.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: mainMemo.ID, + RelatedMemoID: relatedMemo2.ID, + Type: store.MemoRelationComment, + }) + require.NoError(t, err) + + // List with MemoID and Type filter + refType := store.MemoRelationReference + relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &mainMemo.ID, + Type: &refType, + }) + require.NoError(t, err) + require.Len(t, relations, 1) + require.Equal(t, relatedMemo1.ID, relations[0].RelatedMemoID) + + // List with MemoID, RelatedMemoID, and Type filter + commentType := store.MemoRelationComment + relations, err = ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &mainMemo.ID, + RelatedMemoID: &relatedMemo2.ID, + Type: &commentType, + }) + require.NoError(t, err) + require.Len(t, relations, 1) + + ts.Close() +} + +func TestMemoRelationListEmpty(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + memo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "memo-no-relations", + CreatorID: user.ID, + Content: "memo with no relations", + Visibility: store.Public, + }) + require.NoError(t, err) + + // List relations for memo with none + relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &memo.ID, + }) + require.NoError(t, err) + require.Len(t, relations, 0) + + ts.Close() +} + +func TestMemoRelationBidirectional(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + memoA, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "memo-a", + CreatorID: user.ID, + Content: "memo A content", + Visibility: store.Public, + }) + require.NoError(t, err) + + memoB, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "memo-b", + CreatorID: user.ID, + Content: "memo B content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create relation A -> B + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: memoA.ID, + RelatedMemoID: memoB.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + + // Create relation B -> A (reverse direction) + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: memoB.ID, + RelatedMemoID: memoA.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + + // Verify A -> B exists + relationsFromA, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &memoA.ID, + }) + require.NoError(t, err) + require.Len(t, relationsFromA, 1) + require.Equal(t, memoB.ID, relationsFromA[0].RelatedMemoID) + + // Verify B -> A exists + relationsFromB, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &memoB.ID, + }) + require.NoError(t, err) + require.Len(t, relationsFromB, 1) + require.Equal(t, memoA.ID, relationsFromB[0].RelatedMemoID) + + ts.Close() +} + +func TestMemoRelationMultipleRelationsToSameMemo(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + mainMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "main-memo", + CreatorID: user.ID, + Content: "main memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create multiple memos that all relate to the main memo + for i := 1; i <= 5; i++ { + relatedMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "related-memo-" + string(rune('0'+i)), + CreatorID: user.ID, + Content: "related memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: mainMemo.ID, + RelatedMemoID: relatedMemo.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + } + + // Verify all 5 relations exist + relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &mainMemo.ID, + }) + require.NoError(t, err) + require.Len(t, relations, 5) + + ts.Close() +} diff --git a/store/test/user_setting_test.go b/store/test/user_setting_test.go index 99a40f775..49927d07b 100644 --- a/store/test/user_setting_test.go +++ b/store/test/user_setting_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" storepb "github.com/usememos/memos/proto/gen/store" "github.com/usememos/memos/store" @@ -308,3 +309,332 @@ func TestUserSettingShortcuts(t *testing.T) { ts.Close() } + +func TestUserSettingGetUserByPATHash(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create a PAT with a known hash + patHash := "test-pat-hash-12345" + pat := &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-test-1", + TokenHash: patHash, + Description: "Test PAT for lookup", + } + err = ts.AddUserPersonalAccessToken(ctx, user.ID, pat) + require.NoError(t, err) + + // Lookup user by PAT hash + result, err := ts.GetUserByPATHash(ctx, patHash) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, user.ID, result.UserID) + require.NotNil(t, result.User) + require.Equal(t, user.Username, result.User.Username) + require.NotNil(t, result.PAT) + require.Equal(t, "pat-test-1", result.PAT.TokenId) + require.Equal(t, "Test PAT for lookup", result.PAT.Description) + + ts.Close() +} + +func TestUserSettingGetUserByPATHashNotFound(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + _, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Lookup non-existent PAT hash + result, err := ts.GetUserByPATHash(ctx, "non-existent-hash") + require.Error(t, err) + require.Nil(t, result) + + ts.Close() +} + +func TestUserSettingGetUserByPATHashMultipleUsers(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user1, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + user2, err := createTestingUserWithRole(ctx, ts, "user2", store.RoleUser) + require.NoError(t, err) + + // Create PATs for both users + pat1Hash := "user1-pat-hash" + err = ts.AddUserPersonalAccessToken(ctx, user1.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-user1", + TokenHash: pat1Hash, + Description: "User 1 PAT", + }) + require.NoError(t, err) + + pat2Hash := "user2-pat-hash" + err = ts.AddUserPersonalAccessToken(ctx, user2.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-user2", + TokenHash: pat2Hash, + Description: "User 2 PAT", + }) + require.NoError(t, err) + + // Lookup user1's PAT + result1, err := ts.GetUserByPATHash(ctx, pat1Hash) + require.NoError(t, err) + require.Equal(t, user1.ID, result1.UserID) + require.Equal(t, user1.Username, result1.User.Username) + + // Lookup user2's PAT + result2, err := ts.GetUserByPATHash(ctx, pat2Hash) + require.NoError(t, err) + require.Equal(t, user2.ID, result2.UserID) + require.Equal(t, user2.Username, result2.User.Username) + + ts.Close() +} + +func TestUserSettingGetUserByPATHashMultiplePATsSameUser(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create multiple PATs for the same user + pat1Hash := "first-pat-hash" + err = ts.AddUserPersonalAccessToken(ctx, user.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-1", + TokenHash: pat1Hash, + Description: "First PAT", + }) + require.NoError(t, err) + + pat2Hash := "second-pat-hash" + err = ts.AddUserPersonalAccessToken(ctx, user.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-2", + TokenHash: pat2Hash, + Description: "Second PAT", + }) + require.NoError(t, err) + + // Both PATs should resolve to the same user + result1, err := ts.GetUserByPATHash(ctx, pat1Hash) + require.NoError(t, err) + require.Equal(t, user.ID, result1.UserID) + require.Equal(t, "pat-1", result1.PAT.TokenId) + + result2, err := ts.GetUserByPATHash(ctx, pat2Hash) + require.NoError(t, err) + require.Equal(t, user.ID, result2.UserID) + require.Equal(t, "pat-2", result2.PAT.TokenId) + + ts.Close() +} + +func TestUserSettingUpdatePATLastUsed(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create a PAT + patHash := "pat-hash-for-update" + err = ts.AddUserPersonalAccessToken(ctx, user.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-update-test", + TokenHash: patHash, + Description: "PAT for update test", + }) + require.NoError(t, err) + + // Update last used timestamp + now := timestamppb.Now() + err = ts.UpdatePATLastUsed(ctx, user.ID, "pat-update-test", now) + require.NoError(t, err) + + // Verify the update + pats, err := ts.GetUserPersonalAccessTokens(ctx, user.ID) + require.NoError(t, err) + require.Len(t, pats, 1) + require.NotNil(t, pats[0].LastUsedAt) + + ts.Close() +} + +func TestUserSettingGetUserByPATHashWithExpiredToken(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create a PAT with expiration info + patHash := "pat-hash-with-expiry" + expiresAt := timestamppb.Now() + pat := &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-expiry-test", + TokenHash: patHash, + Description: "PAT with expiry", + ExpiresAt: expiresAt, + } + err = ts.AddUserPersonalAccessToken(ctx, user.ID, pat) + require.NoError(t, err) + + // Should still be able to look up by hash (expiry check is done at auth level) + result, err := ts.GetUserByPATHash(ctx, patHash) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, user.ID, result.UserID) + require.NotNil(t, result.PAT.ExpiresAt) + + ts.Close() +} + +func TestUserSettingGetUserByPATHashAfterRemoval(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create a PAT + patHash := "pat-hash-to-remove" + err = ts.AddUserPersonalAccessToken(ctx, user.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-remove-test", + TokenHash: patHash, + Description: "PAT to be removed", + }) + require.NoError(t, err) + + // Verify it exists + result, err := ts.GetUserByPATHash(ctx, patHash) + require.NoError(t, err) + require.NotNil(t, result) + + // Remove the PAT + err = ts.RemoveUserPersonalAccessToken(ctx, user.ID, "pat-remove-test") + require.NoError(t, err) + + // Should no longer be found + result, err = ts.GetUserByPATHash(ctx, patHash) + require.Error(t, err) + require.Nil(t, result) + + ts.Close() +} + +func TestUserSettingGetUserByPATHashSpecialCharacters(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create PATs with special characters in hash (simulating real hash values) + testCases := []struct { + tokenID string + tokenHash string + }{ + {"pat-special-1", "abc123+/=XYZ"}, + {"pat-special-2", "sha256:abcdef1234567890"}, + {"pat-special-3", "$2a$10$N9qo8uLOickgx2ZMRZoMy"}, + } + + for _, tc := range testCases { + err = ts.AddUserPersonalAccessToken(ctx, user.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: tc.tokenID, + TokenHash: tc.tokenHash, + Description: "PAT with special chars", + }) + require.NoError(t, err) + + // Verify lookup works with special characters + result, err := ts.GetUserByPATHash(ctx, tc.tokenHash) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, tc.tokenID, result.PAT.TokenId) + } + + ts.Close() +} + +func TestUserSettingGetUserByPATHashLargeTokenCount(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create many PATs for the same user + tokenCount := 10 + hashes := make([]string, tokenCount) + for i := 0; i < tokenCount; i++ { + hashes[i] = "pat-hash-" + string(rune('A'+i)) + "-large-test" + err = ts.AddUserPersonalAccessToken(ctx, user.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-large-" + string(rune('A'+i)), + TokenHash: hashes[i], + Description: "PAT for large count test", + }) + require.NoError(t, err) + } + + // Verify each hash can be looked up + for i, hash := range hashes { + result, err := ts.GetUserByPATHash(ctx, hash) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, user.ID, result.UserID) + require.Equal(t, "pat-large-"+string(rune('A'+i)), result.PAT.TokenId) + } + + ts.Close() +} + +func TestUserSettingMultipleSettingTypes(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create GENERAL setting + _, err = ts.UpsertUserSetting(ctx, &storepb.UserSetting{ + UserId: user.ID, + Key: storepb.UserSetting_GENERAL, + Value: &storepb.UserSetting_General{General: &storepb.GeneralUserSetting{Locale: "ja"}}, + }) + require.NoError(t, err) + + // Create SHORTCUTS setting + _, err = ts.UpsertUserSetting(ctx, &storepb.UserSetting{ + UserId: user.ID, + Key: storepb.UserSetting_SHORTCUTS, + Value: &storepb.UserSetting_Shortcuts{Shortcuts: &storepb.ShortcutsUserSetting{ + Shortcuts: []*storepb.ShortcutsUserSetting_Shortcut{ + {Id: "s1", Title: "Shortcut 1"}, + }, + }}, + }) + require.NoError(t, err) + + // Add a PAT + err = ts.AddUserPersonalAccessToken(ctx, user.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-multi", + TokenHash: "hash-multi", + }) + require.NoError(t, err) + + // List all settings for user + settings, err := ts.ListUserSettings(ctx, &store.FindUserSetting{UserID: &user.ID}) + require.NoError(t, err) + require.Len(t, settings, 3) + + // Verify each setting type + generalSetting, err := ts.GetUserSetting(ctx, &store.FindUserSetting{UserID: &user.ID, Key: storepb.UserSetting_GENERAL}) + require.NoError(t, err) + require.Equal(t, "ja", generalSetting.GetGeneral().Locale) + + shortcutsSetting, err := ts.GetUserSetting(ctx, &store.FindUserSetting{UserID: &user.ID, Key: storepb.UserSetting_SHORTCUTS}) + require.NoError(t, err) + require.Len(t, shortcutsSetting.GetShortcuts().Shortcuts, 1) + + patsSetting, err := ts.GetUserSetting(ctx, &store.FindUserSetting{UserID: &user.ID, Key: storepb.UserSetting_PERSONAL_ACCESS_TOKENS}) + require.NoError(t, err) + require.Len(t, patsSetting.GetPersonalAccessTokens().Tokens, 1) + + ts.Close() +} From 6899c2f66af6141920b1caf0cfbaa652bfb31dea Mon Sep 17 00:00:00 2001 From: Johnny Date: Mon, 12 Jan 2026 22:32:38 +0800 Subject: [PATCH 21/86] chore: fix golangci-lint --- store/test/idp_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/store/test/idp_test.go b/store/test/idp_test.go index d80cf488b..5df4da9e8 100644 --- a/store/test/idp_test.go +++ b/store/test/idp_test.go @@ -414,7 +414,7 @@ func TestIdentityProviderIdentifierFilterPatterns(t *testing.T) { ts.Close() } -// Helper function to create a test OAuth2 IDP +// Helper function to create a test OAuth2 IDP. func createTestOAuth2IDP(name string) *storepb.IdentityProvider { return &storepb.IdentityProvider{ Name: name, From f58533003b173eff766c26ef19713e92fa612412 Mon Sep 17 00:00:00 2001 From: Johnny Date: Mon, 12 Jan 2026 23:18:04 +0800 Subject: [PATCH 22/86] fix: clean up memo_relation and attachments when deleting memo Fixes #5472 Move cleanup logic to store.DeleteMemo to ensure data consistency: - Delete memo_relation records where memo is source (MemoID) or target (RelatedMemoID) - Delete attachments linked to the memo (including S3/local files) This prevents stale COMMENT records in memo_relation after deleting a memo that has comments. --- server/router/api/v1/memo_service.go | 25 ++++--------------------- store/memo.go | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/server/router/api/v1/memo_service.go b/server/router/api/v1/memo_service.go index db6dd141e..16f5ad165 100644 --- a/server/router/api/v1/memo_service.go +++ b/server/router/api/v1/memo_service.go @@ -497,23 +497,7 @@ func (s *APIV1Service) DeleteMemo(ctx context.Context, request *v1pb.DeleteMemoR } } - if err = s.Store.DeleteMemo(ctx, &store.DeleteMemo{ID: memo.ID}); err != nil { - return nil, status.Errorf(codes.Internal, "failed to delete memo") - } - - // Delete memo relation - if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{MemoID: &memo.ID}); err != nil { - return nil, status.Errorf(codes.Internal, "failed to delete memo relations") - } - - // Delete related attachments. - for _, attachment := range attachments { - if err := s.Store.DeleteAttachment(ctx, &store.DeleteAttachment{ID: attachment.ID}); err != nil { - return nil, status.Errorf(codes.Internal, "failed to delete attachment") - } - } - - // Delete memo comments + // Delete memo comments first (store.DeleteMemo handles their relations and attachments) commentType := store.MemoRelationComment relations, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{RelatedMemoID: &memo.ID, Type: &commentType}) if err != nil { @@ -525,10 +509,9 @@ func (s *APIV1Service) DeleteMemo(ctx context.Context, request *v1pb.DeleteMemoR } } - // Delete memo references - referenceType := store.MemoRelationReference - if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{RelatedMemoID: &memo.ID, Type: &referenceType}); err != nil { - return nil, status.Errorf(codes.Internal, "failed to delete memo references") + // Delete the memo (store.DeleteMemo handles relation and attachment cleanup) + if err = s.Store.DeleteMemo(ctx, &store.DeleteMemo{ID: memo.ID}); err != nil { + return nil, status.Errorf(codes.Internal, "failed to delete memo") } return &emptypb.Empty{}, nil diff --git a/store/memo.go b/store/memo.go index afd71e29a..ce6cde28d 100644 --- a/store/memo.go +++ b/store/memo.go @@ -138,5 +138,22 @@ func (s *Store) UpdateMemo(ctx context.Context, update *UpdateMemo) error { } func (s *Store) DeleteMemo(ctx context.Context, delete *DeleteMemo) error { + // Clean up memo_relation records where this memo is either the source or target. + if err := s.driver.DeleteMemoRelation(ctx, &DeleteMemoRelation{MemoID: &delete.ID}); err != nil { + return err + } + if err := s.driver.DeleteMemoRelation(ctx, &DeleteMemoRelation{RelatedMemoID: &delete.ID}); err != nil { + return err + } + // Clean up attachments linked to this memo. + attachments, err := s.ListAttachments(ctx, &FindAttachment{MemoID: &delete.ID}) + if err != nil { + return err + } + for _, attachment := range attachments { + if err := s.DeleteAttachment(ctx, &DeleteAttachment{ID: attachment.ID}); err != nil { + return err + } + } return s.driver.DeleteMemo(ctx, delete) } From 69b62cccdbf50924438753640447798f0cb157f7 Mon Sep 17 00:00:00 2001 From: Johnny Date: Mon, 12 Jan 2026 23:30:56 +0800 Subject: [PATCH 23/86] test: optimize store tests performance by reusing docker image and reducing build context --- .dockerignore | 4 ++++ store/db/mysql/inbox.go | 6 +++++- store/db/mysql/memo_relation.go | 2 +- store/db/postgres/inbox.go | 6 +++++- store/db/postgres/memo_relation.go | 1 + store/db/sqlite/inbox.go | 6 +++++- store/db/sqlite/memo_relation.go | 1 + store/test/containers.go | 11 ++++++++--- store/test/main_test.go | 17 ++++++++++++++++- 9 files changed, 46 insertions(+), 8 deletions(-) diff --git a/.dockerignore b/.dockerignore index a0ae8ea54..c4ba8e1bb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,5 @@ web/node_modules +.git +build/ +tmp/ +memos \ No newline at end of file diff --git a/store/db/mysql/inbox.go b/store/db/mysql/inbox.go index ec20a8ebe..9964bf9c2 100644 --- a/store/db/mysql/inbox.go +++ b/store/db/mysql/inbox.go @@ -63,7 +63,11 @@ func (d *DB) ListInboxes(ctx context.Context, find *store.FindInbox) ([]*store.I if find.MessageType != nil { // Filter by message type using JSON extraction // Note: The type field in JSON is stored as string representation of the enum name - where, args = append(where, "JSON_EXTRACT(`message`, '$.type') = ?"), append(args, find.MessageType.String()) + if *find.MessageType == storepb.InboxMessage_TYPE_UNSPECIFIED { + where, args = append(where, "(JSON_EXTRACT(`message`, '$.type') IS NULL OR JSON_EXTRACT(`message`, '$.type') = ?)"), append(args, find.MessageType.String()) + } else { + where, args = append(where, "JSON_EXTRACT(`message`, '$.type') = ?"), append(args, find.MessageType.String()) + } } query := "SELECT `id`, UNIX_TIMESTAMP(`created_ts`), `sender_id`, `receiver_id`, `status`, `message` FROM `inbox` WHERE " + strings.Join(where, " AND ") + " ORDER BY `created_ts` DESC" diff --git a/store/db/mysql/memo_relation.go b/store/db/mysql/memo_relation.go index 3116903e0..71b73be6f 100644 --- a/store/db/mysql/memo_relation.go +++ b/store/db/mysql/memo_relation.go @@ -10,7 +10,7 @@ import ( ) func (d *DB) UpsertMemoRelation(ctx context.Context, create *store.MemoRelation) (*store.MemoRelation, error) { - stmt := "INSERT INTO `memo_relation` (`memo_id`, `related_memo_id`, `type`) VALUES (?, ?, ?)" + stmt := "INSERT INTO `memo_relation` (`memo_id`, `related_memo_id`, `type`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `type` = `type`" _, err := d.db.ExecContext( ctx, stmt, diff --git a/store/db/postgres/inbox.go b/store/db/postgres/inbox.go index 93bee9913..40be94b3b 100644 --- a/store/db/postgres/inbox.go +++ b/store/db/postgres/inbox.go @@ -54,7 +54,11 @@ func (d *DB) ListInboxes(ctx context.Context, find *store.FindInbox) ([]*store.I // Filter by message type using PostgreSQL JSON extraction // Note: The type field in JSON is stored as string representation of the enum name // Cast to JSONB since the column is TEXT - where, args = append(where, "message::JSONB->>'type' = "+placeholder(len(args)+1)), append(args, find.MessageType.String()) + if *find.MessageType == storepb.InboxMessage_TYPE_UNSPECIFIED { + where, args = append(where, "(message::JSONB->>'type' IS NULL OR message::JSONB->>'type' = "+placeholder(len(args)+1)+")"), append(args, find.MessageType.String()) + } else { + where, args = append(where, "message::JSONB->>'type' = "+placeholder(len(args)+1)), append(args, find.MessageType.String()) + } } query := "SELECT id, created_ts, sender_id, receiver_id, status, message FROM inbox WHERE " + strings.Join(where, " AND ") + " ORDER BY created_ts DESC" diff --git a/store/db/postgres/memo_relation.go b/store/db/postgres/memo_relation.go index 881291b8a..a2f2817c7 100644 --- a/store/db/postgres/memo_relation.go +++ b/store/db/postgres/memo_relation.go @@ -17,6 +17,7 @@ func (d *DB) UpsertMemoRelation(ctx context.Context, create *store.MemoRelation) type ) VALUES (` + placeholders(3) + `) + ON CONFLICT (memo_id, related_memo_id, type) DO UPDATE SET type = EXCLUDED.type RETURNING memo_id, related_memo_id, type ` memoRelation := &store.MemoRelation{} diff --git a/store/db/sqlite/inbox.go b/store/db/sqlite/inbox.go index 2ab8e68d0..bb8decbc4 100644 --- a/store/db/sqlite/inbox.go +++ b/store/db/sqlite/inbox.go @@ -55,7 +55,11 @@ func (d *DB) ListInboxes(ctx context.Context, find *store.FindInbox) ([]*store.I if find.MessageType != nil { // Filter by message type using JSON extraction // Note: The type field in JSON is stored as string representation of the enum name - where, args = append(where, "JSON_EXTRACT(`message`, '$.type') = ?"), append(args, find.MessageType.String()) + if *find.MessageType == storepb.InboxMessage_TYPE_UNSPECIFIED { + where, args = append(where, "(JSON_EXTRACT(`message`, '$.type') IS NULL OR JSON_EXTRACT(`message`, '$.type') = ?)"), append(args, find.MessageType.String()) + } else { + where, args = append(where, "JSON_EXTRACT(`message`, '$.type') = ?"), append(args, find.MessageType.String()) + } } query := "SELECT `id`, `created_ts`, `sender_id`, `receiver_id`, `status`, `message` FROM `inbox` WHERE " + strings.Join(where, " AND ") + " ORDER BY `created_ts` DESC" diff --git a/store/db/sqlite/memo_relation.go b/store/db/sqlite/memo_relation.go index 3e63c7002..5eed62e74 100644 --- a/store/db/sqlite/memo_relation.go +++ b/store/db/sqlite/memo_relation.go @@ -17,6 +17,7 @@ func (d *DB) UpsertMemoRelation(ctx context.Context, create *store.MemoRelation) type ) VALUES (?, ?, ?) + ON CONFLICT(memo_id, related_memo_id, type) DO UPDATE SET type = excluded.type RETURNING memo_id, related_memo_id, type ` memoRelation := &store.MemoRelation{} diff --git a/store/test/containers.go b/store/test/containers.go index c5e139306..38959ec2a 100644 --- a/store/test/containers.go +++ b/store/test/containers.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "os" "strings" "sync" "sync/atomic" @@ -403,9 +404,13 @@ func StartMemosContainer(ctx context.Context, cfg MemosContainerConfig) (testcon // Use local Dockerfile build or remote image if cfg.Version == "local" { - req.FromDockerfile = testcontainers.FromDockerfile{ - Context: "../../", - Dockerfile: "store/test/Dockerfile", // Simple Dockerfile without BuildKit requirements + if os.Getenv("MEMOS_TEST_IMAGE_BUILT") == "1" { + req.Image = "memos-test:local" + } else { + req.FromDockerfile = testcontainers.FromDockerfile{ + Context: "../../", + Dockerfile: "store/test/Dockerfile", // Simple Dockerfile without BuildKit requirements + } } } else { req.Image = fmt.Sprintf("%s:%s", MemosDockerImage, cfg.Version) diff --git a/store/test/main_test.go b/store/test/main_test.go index 97a632765..ad6bb80ca 100644 --- a/store/test/main_test.go +++ b/store/test/main_test.go @@ -26,13 +26,28 @@ func runAllDrivers() { _, currentFile, _, _ := runtime.Caller(0) projectRoot := filepath.Dir(filepath.Dir(filepath.Dir(currentFile))) + // Build the docker image once for all tests to use + fmt.Println("Building memos docker image for tests (memos-test:local)...") + buildCmd := exec.Command("docker", "build", "-f", "store/test/Dockerfile", "-t", "memos-test:local", ".") + buildCmd.Dir = projectRoot + buildCmd.Stdout = os.Stdout + buildCmd.Stderr = os.Stderr + if err := buildCmd.Run(); err != nil { + fmt.Printf("Failed to build docker image: %v\n", err) + // We don't exit here, we let the tests try to run (and maybe fail or rebuild) + // strictly speaking we should probably fail, but let's be robust. + // Actually, if build fails, tests relying on it will fail or try to rebuild. + // Let's exit to be clear. + os.Exit(1) + } + var failed []string for _, driver := range drivers { fmt.Printf("\n==================== %s ====================\n\n", driver) cmd := exec.Command("go", "test", "-v", "-count=1", "./store/test/...") cmd.Dir = projectRoot - cmd.Env = append(os.Environ(), "DRIVER="+driver) + cmd.Env = append(os.Environ(), "DRIVER="+driver, "MEMOS_TEST_IMAGE_BUILT=1") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr From c45a59549a04f3632d609909e51b1e16f70a82f8 Mon Sep 17 00:00:00 2001 From: Johnny Date: Mon, 12 Jan 2026 23:36:48 +0800 Subject: [PATCH 24/86] fix: replace os.Exit with panic for clearer error handling --- store/test/main_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/store/test/main_test.go b/store/test/main_test.go index ad6bb80ca..5c4e19274 100644 --- a/store/test/main_test.go +++ b/store/test/main_test.go @@ -38,7 +38,7 @@ func runAllDrivers() { // strictly speaking we should probably fail, but let's be robust. // Actually, if build fails, tests relying on it will fail or try to rebuild. // Let's exit to be clear. - os.Exit(1) + panic(fmt.Sprintf("failed to build docker image: %v", err)) } var failed []string From 61dbca8dc22bf6f709501d8568538dacec40a1c1 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 13 Jan 2026 20:55:21 +0800 Subject: [PATCH 25/86] fix: prevent browser cache from serving stale memo data (#5470) This fixes a critical data loss issue where users editing the same memo on multiple devices would overwrite each other's changes due to aggressive browser caching, particularly in Chromium-based browsers and PWAs. Changes: - Backend: Add Cache-Control headers to all API responses to prevent browser HTTP caching - Frontend: Force fresh fetch from server when opening memo editor by invalidating React Query cache - Frontend: Reduce memo query staleTime from 60s to 10s for better collaborative editing support Fixes #5470 --- server/router/api/v1/connect_interceptors.go | 14 +++++++++++++- web/src/components/MemoEditor/hooks/useMemoInit.ts | 9 ++++++++- web/src/hooks/useMemoQueries.ts | 2 +- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/server/router/api/v1/connect_interceptors.go b/server/router/api/v1/connect_interceptors.go index 03eb35de4..348c89279 100644 --- a/server/router/api/v1/connect_interceptors.go +++ b/server/router/api/v1/connect_interceptors.go @@ -50,7 +50,19 @@ func (*MetadataInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc // Set metadata in context so services can use metadata.FromIncomingContext() ctx = metadata.NewIncomingContext(ctx, md) - return next(ctx, req) + + // Execute the request + resp, err := next(ctx, req) + + // Prevent browser caching of API responses to avoid stale data issues + // See: https://github.com/usememos/memos/issues/5470 + if resp != nil { + resp.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + resp.Header().Set("Pragma", "no-cache") + resp.Header().Set("Expires", "0") + } + + return resp, err } } diff --git a/web/src/components/MemoEditor/hooks/useMemoInit.ts b/web/src/components/MemoEditor/hooks/useMemoInit.ts index 7586c0e62..6cdc0f8f6 100644 --- a/web/src/components/MemoEditor/hooks/useMemoInit.ts +++ b/web/src/components/MemoEditor/hooks/useMemoInit.ts @@ -1,4 +1,6 @@ +import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef } from "react"; +import { memoKeys } from "@/hooks/useMemoQueries"; import type { Visibility } from "@/types/proto/api/v1/memo_service_pb"; import type { EditorRefActions } from "../Editor"; import { cacheService, memoService } from "../services"; @@ -13,6 +15,7 @@ export const useMemoInit = ( defaultVisibility?: Visibility, ) => { const { actions, dispatch } = useEditorContext(); + const queryClient = useQueryClient(); const initializedRef = useRef(false); useEffect(() => { @@ -24,6 +27,10 @@ export const useMemoInit = ( try { if (memoName) { + // Force refetch from server to prevent stale data issues + // See: https://github.com/usememos/memos/issues/5470 + await queryClient.invalidateQueries({ queryKey: memoKeys.detail(memoName) }); + // Load existing memo const loadedState = await memoService.load(memoName); dispatch( @@ -58,5 +65,5 @@ export const useMemoInit = ( }; init(); - }, [memoName, cacheKey, username, autoFocus, defaultVisibility, actions, dispatch, editorRef]); + }, [memoName, cacheKey, username, autoFocus, defaultVisibility, actions, dispatch, editorRef, queryClient]); }; diff --git a/web/src/hooks/useMemoQueries.ts b/web/src/hooks/useMemoQueries.ts index bb61426ed..6cb107e5a 100644 --- a/web/src/hooks/useMemoQueries.ts +++ b/web/src/hooks/useMemoQueries.ts @@ -53,7 +53,7 @@ export function useMemo(name: string, options?: { enabled?: boolean }) { return memo; }, enabled: options?.enabled ?? true, - staleTime: 1000 * 60, // 1 minute - memos can be edited frequently + staleTime: 1000 * 10, // 10 seconds - reduced to prevent stale data in collaborative editing }); } From 4e34ef22bf6ce89ce76f71623bc96ada71f07462 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 13 Jan 2026 21:19:31 +0800 Subject: [PATCH 26/86] fix: improve editor auto-scroll and Safari IME handling (#5469) - Use `textarea-caret` for precise cursor position calculation instead of line approximation - Update `scrollToCursor` to scroll to the actual cursor position - Fix Safari double-enter issue with IME in list completion --- .../components/MemoEditor/Editor/index.tsx | 23 +++++++++++++++--- .../MemoEditor/Editor/useListCompletion.ts | 24 ++++++++++++++++++- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/web/src/components/MemoEditor/Editor/index.tsx b/web/src/components/MemoEditor/Editor/index.tsx index 84ad32c2f..1b66b9bb6 100644 --- a/web/src/components/MemoEditor/Editor/index.tsx +++ b/web/src/components/MemoEditor/Editor/index.tsx @@ -1,4 +1,5 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react"; +import getCaretCoordinates from "textarea-caret"; import { cn } from "@/lib/utils"; import { EDITOR_HEIGHT } from "../constants"; import type { EditorProps } from "../types"; @@ -74,9 +75,12 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward getEditor: () => editorRef.current, focus: () => editorRef.current?.focus(), scrollToCursor: () => { - if (editorRef.current) { - editorRef.current.scrollTop = editorRef.current.scrollHeight; - } + const editor = editorRef.current; + if (!editor) return; + + const caret = getCaretCoordinates(editor, editor.selectionEnd); + // Scroll to center cursor vertically + editor.scrollTop = Math.max(0, caret.top - editor.clientHeight / 2); }, insertText: (content = "", prefix = "", suffix = "") => { const editor = editorRef.current; @@ -148,6 +152,19 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward if (editorRef.current) { handleContentChangeCallback(editorRef.current.value); updateEditorHeight(); + + // Auto-scroll to keep cursor visible when typing + // See: https://github.com/usememos/memos/issues/5469 + const editor = editorRef.current; + const caret = getCaretCoordinates(editor, editor.selectionEnd); + const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 24; + + // Scroll if cursor is near or beyond bottom edge (within 2 lines) + const viewportBottom = editor.scrollTop + editor.clientHeight; + if (caret.top + lineHeight * 2 > viewportBottom) { + // Scroll to center cursor vertically + editor.scrollTop = Math.max(0, caret.top - editor.clientHeight / 2); + } } }, [handleContentChangeCallback, updateEditorHeight]); diff --git a/web/src/components/MemoEditor/Editor/useListCompletion.ts b/web/src/components/MemoEditor/Editor/useListCompletion.ts index 69c2d329e..d25258321 100644 --- a/web/src/components/MemoEditor/Editor/useListCompletion.ts +++ b/web/src/components/MemoEditor/Editor/useListCompletion.ts @@ -24,15 +24,30 @@ export function useListCompletion({ editorRef, editorActions, isInIME }: UseList const editorActionsRef = useRef(editorActions); editorActionsRef.current = editorActions; + // Track when composition ends to handle Safari race condition + // Safari fires keydown(Enter) immediately after compositionend, while Chrome doesn't + // See: https://github.com/usememos/memos/issues/5469 + const lastCompositionEndRef = useRef(0); + useEffect(() => { const editor = editorRef.current; if (!editor) return; + const handleCompositionEnd = () => { + lastCompositionEndRef.current = Date.now(); + }; + const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Enter" || isInIMERef.current || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) { return; } + // Safari fix: Ignore Enter key within 100ms of composition end + // This prevents double-enter behavior when confirming IME input in lists + if (Date.now() - lastCompositionEndRef.current < 100) { + return; + } + const actions = editorActionsRef.current; const cursorPosition = actions.getCursorPosition(); const contentBeforeCursor = actions.getContent().substring(0, cursorPosition); @@ -51,10 +66,17 @@ export function useListCompletion({ editorRef, editorActions, isInIME }: UseList } else { const continuation = generateListContinuation(listInfo); actions.insertText("\n" + continuation); + + // Auto-scroll to keep cursor visible after inserting list item + setTimeout(() => actions.scrollToCursor(), 0); } }; + editor.addEventListener("compositionend", handleCompositionEnd); editor.addEventListener("keydown", handleKeyDown); - return () => editor.removeEventListener("keydown", handleKeyDown); + return () => { + editor.removeEventListener("compositionend", handleCompositionEnd); + editor.removeEventListener("keydown", handleKeyDown); + }; }, []); } From 73c301072b191935f3350dc24b0c358b074a8e72 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 13 Jan 2026 21:23:23 +0800 Subject: [PATCH 27/86] refactor: simplify editor scroll logic - Extract `scrollToCaret` helper to deduplicate scroll logic - Unify precise scroll behavior for both manual triggers and auto-scroll --- .../components/MemoEditor/Editor/index.tsx | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/web/src/components/MemoEditor/Editor/index.tsx b/web/src/components/MemoEditor/Editor/index.tsx index 1b66b9bb6..0ef458f9e 100644 --- a/web/src/components/MemoEditor/Editor/index.tsx +++ b/web/src/components/MemoEditor/Editor/index.tsx @@ -52,6 +52,26 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward } }, [handleContentChangeCallback, updateEditorHeight]); + const scrollToCaret = useCallback((options: { force?: boolean } = {}) => { + const editor = editorRef.current; + if (!editor) return; + + const { force = false } = options; + const caret = getCaretCoordinates(editor, editor.selectionEnd); + + if (force) { + editor.scrollTop = Math.max(0, caret.top - editor.clientHeight / 2); + return; + } + + const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 24; + const viewportBottom = editor.scrollTop + editor.clientHeight; + // Scroll if cursor is near or beyond bottom edge (within 2 lines) + if (caret.top + lineHeight * 2 > viewportBottom) { + editor.scrollTop = Math.max(0, caret.top - editor.clientHeight / 2); + } + }, []); + useEffect(() => { if (editorRef.current && initialContent) { editorRef.current.value = initialContent; @@ -75,12 +95,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward getEditor: () => editorRef.current, focus: () => editorRef.current?.focus(), scrollToCursor: () => { - const editor = editorRef.current; - if (!editor) return; - - const caret = getCaretCoordinates(editor, editor.selectionEnd); - // Scroll to center cursor vertically - editor.scrollTop = Math.max(0, caret.top - editor.clientHeight / 2); + scrollToCaret({ force: true }); }, insertText: (content = "", prefix = "", suffix = "") => { const editor = editorRef.current; @@ -143,7 +158,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward updateContent(); }, }), - [updateContent], + [updateContent, scrollToCaret], ); useImperativeHandle(ref, () => editorActions, [editorActions]); @@ -155,18 +170,9 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward // Auto-scroll to keep cursor visible when typing // See: https://github.com/usememos/memos/issues/5469 - const editor = editorRef.current; - const caret = getCaretCoordinates(editor, editor.selectionEnd); - const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 24; - - // Scroll if cursor is near or beyond bottom edge (within 2 lines) - const viewportBottom = editor.scrollTop + editor.clientHeight; - if (caret.top + lineHeight * 2 > viewportBottom) { - // Scroll to center cursor vertically - editor.scrollTop = Math.max(0, caret.top - editor.clientHeight / 2); - } + scrollToCaret(); } - }, [handleContentChangeCallback, updateEditorHeight]); + }, [handleContentChangeCallback, updateEditorHeight, scrollToCaret]); // Auto-complete markdown lists when pressing Enter useListCompletion({ From 253e79c111116ea5ebaf988ad3c49d027618440e Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 13 Jan 2026 23:19:43 +0800 Subject: [PATCH 28/86] style: remove unnecessary font-weight classes for cleaner UI --- web/src/components/MemoEditor/Editor/SlashCommands.tsx | 2 +- .../components/MemoEditor/components/AttachmentList.tsx | 8 ++++---- .../components/MemoEditor/components/LocationDialog.tsx | 8 ++++---- .../components/MemoEditor/components/LocationDisplay.tsx | 2 +- web/src/components/MemoEditor/components/RelationList.tsx | 2 +- .../MemoView/components/metadata/AttachmentList.tsx | 2 +- .../MemoView/components/metadata/SectionHeader.tsx | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/web/src/components/MemoEditor/Editor/SlashCommands.tsx b/web/src/components/MemoEditor/Editor/SlashCommands.tsx index b00bbd008..d1ab6c12d 100644 --- a/web/src/components/MemoEditor/Editor/SlashCommands.tsx +++ b/web/src/components/MemoEditor/Editor/SlashCommands.tsx @@ -33,7 +33,7 @@ const SlashCommands = ({ editorRef, editorActions, commands }: SlashCommandsProp onItemSelect={handleItemSelect} getItemKey={(cmd) => cmd.name} renderItem={(cmd) => ( - + / {cmd.name} diff --git a/web/src/components/MemoEditor/components/AttachmentList.tsx b/web/src/components/MemoEditor/components/AttachmentList.tsx index 4aa6d1716..dda1d3bd0 100644 --- a/web/src/components/MemoEditor/components/AttachmentList.tsx +++ b/web/src/components/MemoEditor/components/AttachmentList.tsx @@ -27,7 +27,7 @@ const AttachmentItemCard: FC<{ return (
-
+
{category === "image" && thumbnailUrl ? ( ) : ( @@ -36,7 +36,7 @@ const AttachmentItemCard: FC<{
- + {filename} @@ -51,7 +51,7 @@ const AttachmentItemCard: FC<{
-
+
{onMoveUp && (