Unverified Commit acddef1f authored by memoclaw's avatar memoclaw Committed by GitHub

fix(api): switch user resource names to usernames (#5779)

Co-authored-by: 's avatarmemoclaw <265580040+memoclaw@users.noreply.github.com>
parent 2327f4e3
## Background & Context
User resources in Memos v1 are exposed through Connect/gRPC-Gateway handlers in `server/router/api/v1`, proto resource definitions in `proto/api/v1`, frontend profile flows in `web/src`, and MCP JSON helpers in `server/router/mcp`. The store schema already persists both an internal integer `id` and a unique `username` for each user. The GitHub issue reports that public user resource names such as `users/2` are still emitted across responses and nested user-scoped resources. Existing code already mixes identifier forms: `GetUser` accepts either `users/{id}` or `users/{username}`, the fileserver avatar route accepts either identifier, and the frontend profile page already enters the API through `users/{username}` before reusing the returned `user.name`.
## Issue Statement
Across the v1 API surface, canonical user resource names are currently constructed from `store.User.ID` rather than `store.User.Username`, and many handlers parse those emitted names back into integers for authorization and lookup. As a result, top-level user resources and nested user-scoped references in settings, stats, shortcuts, webhooks, notifications, memo creators, reactions, and MCP payloads expose sequential database IDs and couple downstream callers to integer-based user tokens in server-emitted names.
## Current State
- `store/user.go:26-42` defines `store.User` with both `ID int32` and `Username string`; `store/migration/sqlite/LATEST.sql:10-21` declares `username TEXT NOT NULL UNIQUE`.
- `server/router/api/v1/user_service.go:72-102` handles `GetUser` by extracting `users/{id_or_username}` and resolving either a numeric ID or a username; `server/router/api/v1/user_service.go:914-937` still serializes `User.name` as `users/{id}` and derives avatar URLs from that name.
- `server/router/api/v1/resource_name.go:67-89` has two different parsing paths: `ExtractUserIDFromName` only accepts numeric user tokens, while `extractUserIdentifierFromName` accepts either token and is currently only used by `GetUser`.
- `server/router/api/v1/user_service.go:335-369`, `server/router/api/v1/user_service.go:372-460`, `server/router/api/v1/user_service.go:463-517`, `server/router/api/v1/user_service.go:536-676`, `server/router/api/v1/user_service.go:679-911`, and `server/router/api/v1/user_service.go:1400-1488` parse numeric user segments for settings, personal access tokens, webhooks, and notifications, and emit names such as `users/%d/settings/...`, `users/%d/webhooks/...`, and `users/%d/notifications/%d`.
- `server/router/api/v1/shortcut_service.go:20-43` parses `users/{user}/shortcuts/{shortcut}` by converting the `user` segment to `int32`, and constructs shortcut names as `users/%d/shortcuts/%s`.
- `server/router/api/v1/user_service_stats.go:63-65`, `server/router/api/v1/user_service_stats.go:113`, `server/router/api/v1/user_service_stats.go:132-145`, `server/router/api/v1/user_service_stats.go:214-223` emit `users/%d/stats` and `users/%d/memos/%d`, and resolve stats requests through numeric `ExtractUserIDFromName`.
- `server/router/api/v1/memo_service_converter.go:26-37` serializes `Memo.creator` as `users/{id}`; `server/router/api/v1/reaction_service.go:154-164` serializes `Reaction.creator` as `users/{id}`; `server/router/api/v1/memo_service.go:636-643` and `server/router/api/v1/memo_service.go:815-845` parse `memo.Creator` through the numeric helper for inbox and webhook flows.
- `server/router/mcp/tools_memo.go:75-86`, `server/router/mcp/tools_attachment.go:29-37`, and `server/router/mcp/tools_reaction.go:64-71` plus `server/router/mcp/tools_reaction.go:133-138` serialize creator fields as `users/{id}` in MCP tool output.
- `server/router/fileserver/fileserver.go:153-181` and `server/router/fileserver/fileserver.go:533-539` currently resolve avatar requests by either numeric ID or username.
- `proto/api/v1/user_service.proto:22-29` and `proto/api/v1/user_service.proto:247-256` document `GetUser` accepting both `users/{id}` and `users/{username}`. The same proto file defines the `User` resource at `proto/api/v1/user_service.proto:161-178` and nested user resource formats at `proto/api/v1/user_service.proto:307-317` and `proto/api/v1/user_service.proto:361-373`; example text still uses numeric user tokens such as `users/123/settings/GENERAL`.
- `web/src/pages/UserProfile.tsx:74-86` requests `users/{username}` from the route param, and `web/src/layouts/MainLayout.tsx:37-48` stores the returned canonical `user.name` for later stats requests.
## Non-Goals
- Replacing internal `user.id` primary keys, foreign keys, or existing store schemas.
- Introducing a new opaque UUID-based public user identifier.
- Changing user discovery, public profile visibility, or authorization rules beyond how user resource names are parsed and emitted.
- Adding username history, redirect, or alias preservation for old usernames after a rename.
- Redesigning unrelated resource naming schemes such as memo, attachment, share, or identity-provider identifiers.
## Open Questions
- Which public surfaces are in scope for username-based canonical output? (default: all server-emitted v1 API and MCP payload fields that currently contain `users/{...}` resource names)
- Should legacy numeric inputs continue to resolve on user-scoped endpoints beyond `GetUser`? (default: no, accept only username-based user resource names)
- If a username changes, must previously emitted `users/{old-username}` names continue to resolve? (default: no additional alias or redirect layer; only the current username remains valid)
- Should notification, webhook, shortcut, and personal-access-token child identifiers keep their existing child token formats while only the parent user token changes? (default: yes)
- Does the issue include avatar URLs and other derived file paths that are built from `User.name`? (default: yes, because avatar URLs are emitted from the same canonical user name field)
## Scope
**L** — Current behavior spans `server/router/api/v1`, `server/router/mcp`, `server/router/fileserver`, `proto/api/v1`, frontend consumers in `web/src`, and the request parsers that turn user resource names back into internal IDs. Changing both emitted and accepted user resource names across those surfaces is a broad API contract change rather than a single local edit.
## References
- [AIP-122: Resource names](https://google.aip.dev/122)
- [AIP-123: Resource types](https://google.aip.dev/123)
- [AIP-148: Standard fields](https://google.aip.dev/148)
- [AIP-180: Backwards compatibility](https://google.aip.dev/180)
- [Insecure Direct Object Reference Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Prevention_Cheat_Sheet.html)
- [REST API endpoints for users - GitHub Docs](https://docs.github.com/en/enterprise-server%403.19/rest/users/users)
- [Users API - GitLab Docs](https://docs.gitlab.com/api/users/)
- [API Usage - Gitea Documentation](https://docs.gitea.com/next/development/api-usage)
## Industry Baseline
`AIP-122: Resource names` and `AIP-148: Standard fields` treat `name` as the canonical identifier that clients store and reuse, and expect request `name` and `parent` fields to accept the same resource-name vocabulary across a service. `AIP-122` also allows aliases for lookup, but requires responses to emit the canonical resource name.
`REST API endpoints for users - GitHub Docs` and `API Usage - Gitea Documentation` use username-based public user paths and nested user-scoped routes, while keeping numeric or system-assigned identifiers as separate data or alternate endpoints when a durable internal identifier is required.
`Users API - GitLab Docs` shows a mixed-input compatibility pattern on some endpoints with `id_or_username`, which keeps older callers working while allowing username-oriented public routes.
`Insecure Direct Object Reference Prevention Cheat Sheet` treats enumerable numeric identifiers as a defense-in-depth concern, but not a substitute for authorization. Replacing `users/{id}` with `users/{username}` changes discoverability characteristics, but permission checks still have to enforce access from internal user IDs.
`AIP-180: Backwards compatibility` treats changes to resource-name format and server-generated field construction as breaking. Any design that changes emitted `User.name` values inside `v1` has to preserve as much request compatibility as possible and document the remaining response-format risk explicitly.
## Research Summary
Memos already has most of the prerequisites for username-based canonical names. The schema stores a unique username, `GetUser` already resolves either ID or username, the fileserver avatar route already uses an `identifier` abstraction, and the frontend profile page already starts from `users/{username}`. No database migration is required to identify users by username at the API boundary.
The current coupling problem is concentrated in two places. First, response builders serialize `users/{id}` in many modules, including memo conversion, stats, settings, shortcuts, notifications, webhooks, and MCP JSON helpers. Second, many request handlers assume they can parse a numeric ID back out of those names for authorization and storage lookups.
Research points to a common pattern of canonical public resource names plus server-side resolution to internal IDs. In Memos, switching the canonical token from numeric ID to username can reuse the existing unique username column and existing username lookups, but `AIP-123: Resource types` and `AIP-180: Backwards compatibility` still make clear that changing accepted and emitted resource-name formats inside `v1` is a breaking API contract change. That makes this design a deliberate contract replacement rather than a compatibility layer.
## Design Goals
- All server-emitted v1 and MCP response fields that serialize user resource names under `users/{...}` use the current username token instead of the numeric database ID.
- User-scoped request fields that reference `users/{...}` accept username-based resource names only.
- Authorization, ownership checks, inbox/webhook dispatch, and other internal workflows continue to operate on `store.User.ID` after resolving the public resource name.
- List and batch endpoints avoid introducing per-item user lookups when serializing username-based names.
- No database schema, foreign-key, or storage-key redesign is required.
## Non-Goals
- Replacing internal `user.id` primary keys, foreign keys, or existing store schemas.
- Introducing a new opaque UUID-based public user identifier.
- Changing user discovery, public profile visibility, or authorization rules beyond how user resource names are parsed and emitted.
- Adding username history, redirect, or alias preservation for old usernames after a rename.
- Redesigning unrelated resource naming schemes such as memo, attachment, share, or identity-provider identifiers.
- Adding a new API version as part of this issue.
## Proposed Design
Introduce a single canonical user-name builder in the v1 API layer that serializes `users/{username}` from resolved user data, and route every public user-name emitter through it. This includes `convertUserFromStore`, memo and reaction creator fields, user stats, settings, shortcuts, webhooks, notifications, personal-access-token names, webhook payloads, avatar URLs derived from `User.name`, and the MCP JSON helpers. This satisfies the first design goal and aligns the public resource shape with `AIP-122: Resource names`.
Introduce a shared user-token resolver in `server/router/api/v1` that extracts the `users/{token}` segment, validates it as a username-form resource token, resolves the corresponding `store.User`, and then passes the resolved internal ID into permission checks and storage lookups. This replaces numeric-only parsing in helpers such as `ExtractUserIDFromName`, `ExtractUserIDAndSettingKeyFromName`, shortcut and webhook parsers, personal-access-token deletion, and notification parsing. The fileserver's current `getUserByIdentifier` behavior shows both lookup styles exist today, but the API-layer contract for this issue becomes username-only rather than dual-mode.
Keep child resource tokens unchanged and only change the user segment. For names such as `users/{user}/settings/{setting}`, `users/{user}/webhooks/{webhook}`, `users/{user}/notifications/{notification}`, `users/{user}/shortcuts/{shortcut}`, and `users/{user}/personalAccessTokens/{token}`, the parent `user` token is resolved from the username, while the child token keeps its existing format and storage mapping. This is narrower than redesigning child identifiers and keeps the issue bounded to the user-resource segment.
Use response-side user resolution strategies that match endpoint shape. Single-resource handlers can resolve one user directly and serialize the username immediately. List and batch handlers such as memo conversion, stats aggregation, notifications, and MCP list output should collect distinct user IDs first and resolve usernames once per response, reusing the store's existing user lookup path and cache where available. This keeps username-based output from turning into hidden N+1 query behavior and satisfies the performance goal without changing persistence.
Replace the public user-resource contract rather than extending it. Server-emitted `name`, `parent`, `creator`, and `sender` fields become username-based canonical output, and handlers that currently accept `users/{id}` are updated to require `users/{username}`. `AIP-180: Backwards compatibility` indicates that changing both the construction and accepted format of an existing resource name is a breaking change for clients that persist, compare, or generate old `name` values. The design therefore requires updated proto comments, API examples, handler tests, and release notes to make the new canonical form and the removed numeric form explicit.
Do not add a username alias table in this issue. If a username changes, newly serialized resource names use the current username, and previously emitted username-based names stop resolving unless they match the current username. This keeps the scope aligned with existing `UpdateUser` behavior and avoids introducing a new subsystem for historical username resolution. The alternative of adding permanent old-username aliases was rejected because it expands the problem from canonical serialization into identity-history management.
Do not solve this by adding a second public identifier field and leaving `User.name` numeric. `AIP-122: Resource names` treats `name` as the canonical resource identifier, and the GitHub issue is specifically about the public names currently emitted under `users/{id}`. Adding a second field would preserve the exposed sequential identifier in the canonical slot and fail the primary design goal. Likewise, introducing a new opaque UUID-based public identifier was rejected because the repository already has a unique username field and the issue is scoped to replacing numeric user resource names with that existing identifier.
## Execution Log
### T1: Add username-only user resource helpers
**Status**: Completed
**Files Changed**:
- `server/router/api/v1/user_resource_name.go`
- `server/router/api/v1/resource_name.go`
- `server/router/api/v1/user_service.go`
- `server/router/api/v1/test/user_resource_name_test.go`
**Validation**: `go test -v ./server/router/api/v1/test -run 'TestUserResourceName'` — PASS
**Path Corrections**: Tightened username-token validation so numeric-only `users/1` fails at the resource-name layer instead of falling through to `NotFound`.
**Deviations**: None
### T2: Migrate user-scoped API handlers
**Status**: Completed
**Files Changed**:
- `server/router/api/v1/user_service.go`
- `server/router/api/v1/shortcut_service.go`
- `server/router/api/v1/user_service_stats.go`
- `server/router/api/v1/test/shortcut_service_test.go`
- `server/router/api/v1/test/user_service_stats_test.go`
- `server/router/api/v1/test/user_notification_test.go`
- `server/router/api/v1/test/user_service_registration_test.go`
**Validation**: `go test -v ./server/router/api/v1/test -run 'Test(ListShortcuts|GetShortcut|CreateShortcut|UpdateShortcut|DeleteShortcut|ShortcutFiltering|ShortcutCRUDComplete|GetUserStats_TagCount|ListUserNotifications|UserRegistration)'` — PASS
**Path Corrections**: Updated test fixtures to use valid username-form resource names (`users/testuser`, `users/test-user`) and corrected one stale registration-name expectation during the later broader suite rerun.
**Deviations**: None
### T3: Migrate memo, reaction, MCP, and avatar user references
**Status**: Completed
**Files Changed**:
- `server/router/api/v1/memo_service_converter.go`
- `server/router/api/v1/memo_service.go`
- `server/router/api/v1/reaction_service.go`
- `server/router/mcp/tools_memo.go`
- `server/router/mcp/tools_attachment.go`
- `server/router/mcp/tools_reaction.go`
- `server/router/fileserver/fileserver.go`
- `server/router/api/v1/test/memo_service_test.go`
- `server/router/api/v1/test/reaction_service_test.go`
**Validation**: `go test ./server/router/api/v1/... ./server/router/mcp/... ./server/router/fileserver/...` — PASS
**Path Corrections**: Removed an unused fileserver import after the first package build failed; kept MCP tool helper signatures stable for undeclared callers and switched tool call sites to username-aware wrappers.
**Deviations**: None
### T4: Update contract docs and regression tests
**Status**: Completed
**Files Changed**:
- `proto/api/v1/user_service.proto`
- `proto/api/v1/shortcut_service.proto`
- `web/src/layouts/MainLayout.tsx`
- `web/src/components/MemoExplorer/ShortcutsSection.tsx`
- `server/router/fileserver/README.md`
**Validation**: `go test -v ./server/router/api/v1/test/...` — PASS
**Path Corrections**: None
**Deviations**: None
## Completion Declaration
All tasks completed successfully
## Task List
Task Index
T1: Add username-only user resource helpers [L] — T2: Migrate user-scoped API handlers [L] — T3: Migrate memo, reaction, MCP, and avatar user references [L] — T4: Update contract docs and regression tests [L]
### T1: Add username-only user resource helpers [L]
**Objective**: Establish one v1 API mechanism for serializing `users/{username}` and resolving username-based user resource names back to internal user records, including root `GetUser` handling.
**Size**: L (multiple files, shared identifier logic used across handlers)
**Files**:
- Create: `server/router/api/v1/user_resource_name.go`
- Modify: `server/router/api/v1/resource_name.go`
- Modify: `server/router/api/v1/user_service.go`
- Test: `server/router/api/v1/test/user_resource_name_test.go`
**Implementation**:
1. In `server/router/api/v1/user_resource_name.go`: add the shared helper surface for canonical user-name construction, extracting the `users/{token}` segment, validating the username-form token, and resolving the corresponding `store.User`.
2. In `server/router/api/v1/resource_name.go`: replace `ExtractUserIDFromName()`’s numeric-only behavior with username-oriented resolution helpers or thin wrappers that delegate to the new shared module.
3. In `server/router/api/v1/user_service.go`: update `GetUser()` (~lines 72-102) and `convertUserFromStore()` (~lines 914-937) to use username-only resource names and reject legacy numeric `users/{id}` requests.
4. In `server/router/api/v1/test/user_resource_name_test.go`: add direct coverage for `GetUser users/{username}` success, canonical `User.name == users/{username}`, and rejection of `users/{id}`.
**Boundaries**: Do not migrate nested user-scoped handlers, memo/reaction emitters, MCP output, or fileserver behavior in this task.
**Dependencies**: None
**Expected Outcome**: Shared username-only helper logic exists, root user resources serialize as `users/{username}`, and root numeric user-name requests fail.
**Validation**: `go test -v ./server/router/api/v1/test -run 'TestUserResourceName'` — expected output includes `PASS` and `ok`
### T2: Migrate user-scoped API handlers [L]
**Objective**: Convert user-scoped v1 handlers and nested resource emitters to require `users/{username}` while continuing to authorize and store by resolved internal user ID.
**Size**: L (multiple handlers in one large service plus shortcut and stats code)
**Files**:
- Modify: `server/router/api/v1/user_service.go`
- Modify: `server/router/api/v1/shortcut_service.go`
- Modify: `server/router/api/v1/user_service_stats.go`
- Test: `server/router/api/v1/test/shortcut_service_test.go`
- Test: `server/router/api/v1/test/user_service_stats_test.go`
- Test: `server/router/api/v1/test/user_notification_test.go`
- Test: `server/router/api/v1/test/user_service_registration_test.go`
**Implementation**:
1. In `server/router/api/v1/user_service.go`: update settings, PAT, webhook, and notification parsing/emission paths (~lines 335-911 and ~1400-1488) to resolve `users/{username}` and emit username-based parent/child resource names.
2. In `server/router/api/v1/shortcut_service.go`: update shortcut name parsing and construction (~lines 20-43) plus handler entry points to use username parents and nested names.
3. In `server/router/api/v1/user_service_stats.go`: update stats request parsing and `UserStats.name` / `PinnedMemos` serialization (~lines 63-65, 113, 132-145, 214-223) to use usernames.
4. In the listed tests: replace numeric user-name inputs with username-based parents, assert username-based emitted names, and add numeric-request rejection coverage for representative user-scoped endpoints.
**Boundaries**: Do not change memo/reaction creator fields, MCP JSON output, or fileserver avatar routing in this task.
**Dependencies**: T1
**Expected Outcome**: User settings, notifications, shortcuts, stats, PATs, and webhooks all accept only `users/{username}` and emit only username-based user resource names.
**Validation**: `go test -v ./server/router/api/v1/test -run 'Test(ListShortcuts|GetShortcut|CreateShortcut|UpdateShortcut|DeleteShortcut|ShortcutFiltering|ShortcutCRUDComplete|GetUserStats_TagCount|ListUserNotifications|UserRegistration)'` — expected output includes `PASS` and `ok`
### T3: Migrate memo, reaction, MCP, and avatar user references [L]
**Objective**: Remove numeric user resource names from memo/reaction-related API responses, dependent webhook/inbox flows, MCP JSON output, and avatar URLs/routing.
**Size**: L (cross-package serialization and lookup changes, including response-side user resolution)
**Files**:
- Modify: `server/router/api/v1/memo_service_converter.go`
- Modify: `server/router/api/v1/memo_service.go`
- Modify: `server/router/api/v1/reaction_service.go`
- Modify: `server/router/mcp/tools_memo.go`
- Modify: `server/router/mcp/tools_attachment.go`
- Modify: `server/router/mcp/tools_reaction.go`
- Modify: `server/router/fileserver/fileserver.go`
- Test: `server/router/api/v1/test/memo_service_test.go`
- Test: `server/router/api/v1/test/reaction_service_test.go`
**Implementation**:
1. In `server/router/api/v1/memo_service_converter.go`: update `convertMemoFromStore()` (~lines 16-73) to serialize `Memo.creator` from resolved usernames rather than numeric IDs, using response-side batching or shared lookup helpers so list responses do not regress into hidden per-item lookups.
2. In `server/router/api/v1/reaction_service.go`: update `convertReactionFromStore()` (~lines 154-164) to emit username-based creators.
3. In `server/router/api/v1/memo_service.go`: update memo comment, webhook dispatch, and webhook payload helpers (~lines 636-643 and 815-845) to resolve username-based memo creators before using internal IDs.
4. In `server/router/mcp/tools_memo.go`, `server/router/mcp/tools_attachment.go`, and `server/router/mcp/tools_reaction.go`: replace `users/%d` creator serialization with username-based values.
5. In `server/router/fileserver/fileserver.go`: change avatar lookup to accept username identifiers only and ensure avatar URLs derived from `User.name` continue to resolve under `users/{username}`.
6. In the listed tests: update creator assertions to `users/{username}` and add representative rejection coverage where numeric user names previously flowed through memo/reaction-related paths.
**Boundaries**: Do not update proto comments, README examples, or frontend comments in this task.
**Dependencies**: T1
**Expected Outcome**: Memo/reaction creators, webhook payload creators, MCP creator fields, and avatar-derived user paths no longer expose numeric user IDs.
**Validation**: `go test ./server/router/api/v1/... ./server/router/mcp/... ./server/router/fileserver/...` — expected output includes `ok` for all touched packages
### T4: Update contract docs and regression tests [L]
**Objective**: Align public contract comments/examples and the final regression suite with the username-only user resource-name contract.
**Size**: L (multiple contract/documentation files plus end-to-end regression coverage)
**Files**:
- Modify: `proto/api/v1/user_service.proto`
- Modify: `proto/api/v1/shortcut_service.proto`
- Modify: `web/src/layouts/MainLayout.tsx`
- Modify: `web/src/components/MemoExplorer/ShortcutsSection.tsx`
- Modify: `server/router/fileserver/README.md`
- Modify: `server/router/api/v1/test/user_resource_name_test.go`
- Modify: `server/router/api/v1/test/shortcut_service_test.go`
- Modify: `server/router/api/v1/test/user_service_stats_test.go`
- Modify: `server/router/api/v1/test/user_notification_test.go`
- Modify: `server/router/api/v1/test/memo_service_test.go`
- Modify: `server/router/api/v1/test/reaction_service_test.go`
- Modify: `server/router/api/v1/test/user_service_registration_test.go`
**Implementation**:
1. In `proto/api/v1/user_service.proto` and `proto/api/v1/shortcut_service.proto`: rewrite resource-name comments and examples so they document username-only user resource names and remove `users/{id}` examples.
2. In `web/src/layouts/MainLayout.tsx` and `web/src/components/MemoExplorer/ShortcutsSection.tsx`: update inline comments/examples that still describe numeric user resource names.
3. In `server/router/fileserver/README.md`: replace numeric avatar examples with username-based examples.
4. In the listed test files: finish any remaining request/response assertions so the suite consistently encodes the username-only contract and explicitly rejects numeric user resource names where that contract is externally visible.
**Boundaries**: Do not add schema migrations, generated proto output refreshes, or username-history behavior.
**Dependencies**: T2, T3
**Expected Outcome**: Source comments, examples, and regression tests all describe and enforce a username-only `users/{username}` public contract.
**Validation**: `go test -v ./server/router/api/v1/test/...` — expected output includes `PASS` and `ok`
## Out-of-Scope Tasks
- Database schema or migration changes for the `user` table or foreign keys.
- Username history, alias, redirect, or backward-compatibility layers.
- A new opaque public user identifier or a new API version.
- Opportunistic refactors outside the files listed above.
- Generated code refreshes (`buf generate`) unless a later approved plan revision explicitly requires schema changes.
...@@ -108,6 +108,21 @@ func NewSchema() Schema { ...@@ -108,6 +108,21 @@ func NewSchema() Schema {
SupportsContains: true, SupportsContains: true,
Expressions: map[DialectName]string{}, Expressions: map[DialectName]string{},
}, },
"creator": {
Name: "creator",
Kind: FieldKindScalar,
Type: FieldTypeString,
Column: Column{Table: "memo_creator", Name: "username"},
Expressions: map[DialectName]string{
DialectSQLite: "('users/' || %s)",
DialectMySQL: "CONCAT('users/', %s)",
DialectPostgres: "('users/' || %s)",
},
AllowedComparisonOps: map[ComparisonOperator]bool{
CompareEq: true,
CompareNeq: true,
},
},
"creator_id": { "creator_id": {
Name: "creator_id", Name: "creator_id",
Kind: FieldKindScalar, Kind: FieldKindScalar,
...@@ -228,6 +243,7 @@ func NewSchema() Schema { ...@@ -228,6 +243,7 @@ func NewSchema() Schema {
envOptions := []cel.EnvOption{ envOptions := []cel.EnvOption{
cel.Variable("content", cel.StringType), cel.Variable("content", cel.StringType),
cel.Variable("creator", cel.StringType),
cel.Variable("creator_id", cel.IntType), cel.Variable("creator_id", cel.IntType),
cel.Variable("created_ts", cel.IntType), cel.Variable("created_ts", cel.IntType),
cel.Variable("updated_ts", cel.IntType), cel.Variable("updated_ts", cel.IntType),
......
...@@ -52,13 +52,13 @@ service ShortcutService { ...@@ -52,13 +52,13 @@ service ShortcutService {
message Shortcut { message Shortcut {
option (google.api.resource) = { option (google.api.resource) = {
type: "memos.api.v1/Shortcut" type: "memos.api.v1/Shortcut"
pattern: "users/{user}/shortcuts/{shortcut}" pattern: "users/{username}/shortcuts/{shortcut}"
singular: "shortcut" singular: "shortcut"
plural: "shortcuts" plural: "shortcuts"
}; };
// The resource name of the shortcut. // The resource name of the shortcut.
// Format: users/{user}/shortcuts/{shortcut} // Format: users/{username}/shortcuts/{shortcut}
string name = 1 [(google.api.field_behavior) = IDENTIFIER]; string name = 1 [(google.api.field_behavior) = IDENTIFIER];
// The title of the shortcut. // The title of the shortcut.
...@@ -70,7 +70,7 @@ message Shortcut { ...@@ -70,7 +70,7 @@ message Shortcut {
message ListShortcutsRequest { message ListShortcutsRequest {
// Required. The parent resource where shortcuts are listed. // Required. The parent resource where shortcuts are listed.
// Format: users/{user} // Format: users/{username}
string parent = 1 [ string parent = 1 [
(google.api.field_behavior) = REQUIRED, (google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {child_type: "memos.api.v1/Shortcut"} (google.api.resource_reference) = {child_type: "memos.api.v1/Shortcut"}
...@@ -84,7 +84,7 @@ message ListShortcutsResponse { ...@@ -84,7 +84,7 @@ message ListShortcutsResponse {
message GetShortcutRequest { message GetShortcutRequest {
// Required. The resource name of the shortcut to retrieve. // Required. The resource name of the shortcut to retrieve.
// Format: users/{user}/shortcuts/{shortcut} // Format: users/{username}/shortcuts/{shortcut}
string name = 1 [ string name = 1 [
(google.api.field_behavior) = REQUIRED, (google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "memos.api.v1/Shortcut"} (google.api.resource_reference) = {type: "memos.api.v1/Shortcut"}
...@@ -93,7 +93,7 @@ message GetShortcutRequest { ...@@ -93,7 +93,7 @@ message GetShortcutRequest {
message CreateShortcutRequest { message CreateShortcutRequest {
// Required. The parent resource where this shortcut will be created. // Required. The parent resource where this shortcut will be created.
// Format: users/{user} // Format: users/{username}
string parent = 1 [ string parent = 1 [
(google.api.field_behavior) = REQUIRED, (google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {child_type: "memos.api.v1/Shortcut"} (google.api.resource_reference) = {child_type: "memos.api.v1/Shortcut"}
...@@ -116,7 +116,7 @@ message UpdateShortcutRequest { ...@@ -116,7 +116,7 @@ message UpdateShortcutRequest {
message DeleteShortcutRequest { message DeleteShortcutRequest {
// Required. The resource name of the shortcut to delete. // Required. The resource name of the shortcut to delete.
// Format: users/{user}/shortcuts/{shortcut} // Format: users/{username}/shortcuts/{shortcut}
string name = 1 [ string name = 1 [
(google.api.field_behavior) = REQUIRED, (google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "memos.api.v1/Shortcut"} (google.api.resource_reference) = {type: "memos.api.v1/Shortcut"}
......
...@@ -19,10 +19,8 @@ service UserService { ...@@ -19,10 +19,8 @@ service UserService {
option (google.api.http) = {get: "/api/v1/users"}; option (google.api.http) = {get: "/api/v1/users"};
} }
// GetUser gets a user by ID or username. // GetUser gets a user by username.
// Supports both numeric IDs and username strings: // Format: users/{username} (e.g., users/steven)
// - users/{id} (e.g., users/101)
// - users/{username} (e.g., users/steven)
rpc GetUser(GetUserRequest) returns (User) { rpc GetUser(GetUserRequest) returns (User) {
option (google.api.http) = {get: "/api/v1/{name=users/*}"}; option (google.api.http) = {get: "/api/v1/{name=users/*}"};
option (google.api.method_signature) = "name"; option (google.api.method_signature) = "name";
...@@ -246,10 +244,7 @@ message ListUsersResponse { ...@@ -246,10 +244,7 @@ message ListUsersResponse {
message GetUserRequest { message GetUserRequest {
// Required. The resource name of the user. // Required. The resource name of the user.
// Supports both numeric IDs and username strings: // Format: users/{username}
// - users/{id} (e.g., users/101)
// - users/{username} (e.g., users/steven)
// Format: users/{id_or_username}
string name = 1 [ string name = 1 [
(google.api.field_behavior) = REQUIRED, (google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "memos.api.v1/User"} (google.api.resource_reference) = {type: "memos.api.v1/User"}
...@@ -362,14 +357,14 @@ message ListAllUserStatsResponse { ...@@ -362,14 +357,14 @@ message ListAllUserStatsResponse {
message UserSetting { message UserSetting {
option (google.api.resource) = { option (google.api.resource) = {
type: "memos.api.v1/UserSetting" type: "memos.api.v1/UserSetting"
pattern: "users/{user}/settings/{setting}" pattern: "users/{username}/settings/{setting}"
singular: "userSetting" singular: "userSetting"
plural: "userSettings" plural: "userSettings"
}; };
// The name of the user setting. // The name of the user setting.
// Format: users/{user}/settings/{setting}, {setting} is the key for the setting. // Format: users/{username}/settings/{setting}, {setting} is the key for the setting.
// For example, "users/123/settings/GENERAL" for general settings. // For example, "users/steven/settings/GENERAL" for general settings.
string name = 1 [(google.api.field_behavior) = IDENTIFIER]; string name = 1 [(google.api.field_behavior) = IDENTIFIER];
oneof value { oneof value {
......
...@@ -95,10 +95,8 @@ const ( ...@@ -95,10 +95,8 @@ const (
type UserServiceClient interface { type UserServiceClient interface {
// ListUsers returns a list of users. // ListUsers returns a list of users.
ListUsers(context.Context, *connect.Request[v1.ListUsersRequest]) (*connect.Response[v1.ListUsersResponse], error) ListUsers(context.Context, *connect.Request[v1.ListUsersRequest]) (*connect.Response[v1.ListUsersResponse], error)
// GetUser gets a user by ID or username. // GetUser gets a user by username.
// Supports both numeric IDs and username strings: // Format: users/{username} (e.g., users/steven)
// - users/{id} (e.g., users/101)
// - users/{username} (e.g., users/steven)
GetUser(context.Context, *connect.Request[v1.GetUserRequest]) (*connect.Response[v1.User], error) GetUser(context.Context, *connect.Request[v1.GetUserRequest]) (*connect.Response[v1.User], error)
// CreateUser creates a new user. // CreateUser creates a new user.
CreateUser(context.Context, *connect.Request[v1.CreateUserRequest]) (*connect.Response[v1.User], error) CreateUser(context.Context, *connect.Request[v1.CreateUserRequest]) (*connect.Response[v1.User], error)
...@@ -402,10 +400,8 @@ func (c *userServiceClient) DeleteUserNotification(ctx context.Context, req *con ...@@ -402,10 +400,8 @@ func (c *userServiceClient) DeleteUserNotification(ctx context.Context, req *con
type UserServiceHandler interface { type UserServiceHandler interface {
// ListUsers returns a list of users. // ListUsers returns a list of users.
ListUsers(context.Context, *connect.Request[v1.ListUsersRequest]) (*connect.Response[v1.ListUsersResponse], error) ListUsers(context.Context, *connect.Request[v1.ListUsersRequest]) (*connect.Response[v1.ListUsersResponse], error)
// GetUser gets a user by ID or username. // GetUser gets a user by username.
// Supports both numeric IDs and username strings: // Format: users/{username} (e.g., users/steven)
// - users/{id} (e.g., users/101)
// - users/{username} (e.g., users/steven)
GetUser(context.Context, *connect.Request[v1.GetUserRequest]) (*connect.Response[v1.User], error) GetUser(context.Context, *connect.Request[v1.GetUserRequest]) (*connect.Response[v1.User], error)
// CreateUser creates a new user. // CreateUser creates a new user.
CreateUser(context.Context, *connect.Request[v1.CreateUserRequest]) (*connect.Response[v1.User], error) CreateUser(context.Context, *connect.Request[v1.CreateUserRequest]) (*connect.Response[v1.User], error)
......
...@@ -27,7 +27,7 @@ const ( ...@@ -27,7 +27,7 @@ const (
type Shortcut struct { type Shortcut struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// The resource name of the shortcut. // The resource name of the shortcut.
// Format: users/{user}/shortcuts/{shortcut} // Format: users/{username}/shortcuts/{shortcut}
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
// The title of the shortcut. // The title of the shortcut.
Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
...@@ -91,7 +91,7 @@ func (x *Shortcut) GetFilter() string { ...@@ -91,7 +91,7 @@ func (x *Shortcut) GetFilter() string {
type ListShortcutsRequest struct { type ListShortcutsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// Required. The parent resource where shortcuts are listed. // Required. The parent resource where shortcuts are listed.
// Format: users/{user} // Format: users/{username}
Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"` Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
...@@ -182,7 +182,7 @@ func (x *ListShortcutsResponse) GetShortcuts() []*Shortcut { ...@@ -182,7 +182,7 @@ func (x *ListShortcutsResponse) GetShortcuts() []*Shortcut {
type GetShortcutRequest struct { type GetShortcutRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// Required. The resource name of the shortcut to retrieve. // Required. The resource name of the shortcut to retrieve.
// Format: users/{user}/shortcuts/{shortcut} // Format: users/{username}/shortcuts/{shortcut}
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
...@@ -228,7 +228,7 @@ func (x *GetShortcutRequest) GetName() string { ...@@ -228,7 +228,7 @@ func (x *GetShortcutRequest) GetName() string {
type CreateShortcutRequest struct { type CreateShortcutRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// Required. The parent resource where this shortcut will be created. // Required. The parent resource where this shortcut will be created.
// Format: users/{user} // Format: users/{username}
Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"` Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
// Required. The shortcut to create. // Required. The shortcut to create.
Shortcut *Shortcut `protobuf:"bytes,2,opt,name=shortcut,proto3" json:"shortcut,omitempty"` Shortcut *Shortcut `protobuf:"bytes,2,opt,name=shortcut,proto3" json:"shortcut,omitempty"`
...@@ -346,7 +346,7 @@ func (x *UpdateShortcutRequest) GetUpdateMask() *fieldmaskpb.FieldMask { ...@@ -346,7 +346,7 @@ func (x *UpdateShortcutRequest) GetUpdateMask() *fieldmaskpb.FieldMask {
type DeleteShortcutRequest struct { type DeleteShortcutRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// Required. The resource name of the shortcut to delete. // Required. The resource name of the shortcut to delete.
// Format: users/{user}/shortcuts/{shortcut} // Format: users/{username}/shortcuts/{shortcut}
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
...@@ -393,12 +393,12 @@ var File_api_v1_shortcut_service_proto protoreflect.FileDescriptor ...@@ -393,12 +393,12 @@ var File_api_v1_shortcut_service_proto protoreflect.FileDescriptor
const file_api_v1_shortcut_service_proto_rawDesc = "" + const file_api_v1_shortcut_service_proto_rawDesc = "" +
"\n" + "\n" +
"\x1dapi/v1/shortcut_service.proto\x12\fmemos.api.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\"\xaf\x01\n" + "\x1dapi/v1/shortcut_service.proto\x12\fmemos.api.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\"\xb3\x01\n" +
"\bShortcut\x12\x17\n" + "\bShortcut\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12\x19\n" + "\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12\x19\n" +
"\x05title\x18\x02 \x01(\tB\x03\xe0A\x02R\x05title\x12\x1b\n" + "\x05title\x18\x02 \x01(\tB\x03\xe0A\x02R\x05title\x12\x1b\n" +
"\x06filter\x18\x03 \x01(\tB\x03\xe0A\x01R\x06filter:R\xeaAO\n" + "\x06filter\x18\x03 \x01(\tB\x03\xe0A\x01R\x06filter:V\xeaAS\n" +
"\x15memos.api.v1/Shortcut\x12!users/{user}/shortcuts/{shortcut}*\tshortcuts2\bshortcut\"M\n" + "\x15memos.api.v1/Shortcut\x12%users/{username}/shortcuts/{shortcut}*\tshortcuts2\bshortcut\"M\n" +
"\x14ListShortcutsRequest\x125\n" + "\x14ListShortcutsRequest\x125\n" +
"\x06parent\x18\x01 \x01(\tB\x1d\xe0A\x02\xfaA\x17\x12\x15memos.api.v1/ShortcutR\x06parent\"M\n" + "\x06parent\x18\x01 \x01(\tB\x1d\xe0A\x02\xfaA\x17\x12\x15memos.api.v1/ShortcutR\x06parent\"M\n" +
"\x15ListShortcutsResponse\x124\n" + "\x15ListShortcutsResponse\x124\n" +
......
...@@ -506,11 +506,7 @@ func (x *ListUsersResponse) GetTotalSize() int32 { ...@@ -506,11 +506,7 @@ func (x *ListUsersResponse) GetTotalSize() int32 {
type GetUserRequest struct { type GetUserRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// Required. The resource name of the user. // Required. The resource name of the user.
// Supports both numeric IDs and username strings: // Format: users/{username}
// - users/{id} (e.g., users/101)
// - users/{username} (e.g., users/steven)
//
// Format: users/{id_or_username}
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
// Optional. The fields to return in the response. // Optional. The fields to return in the response.
// If not specified, all fields are returned. // If not specified, all fields are returned.
...@@ -979,8 +975,8 @@ func (x *ListAllUserStatsResponse) GetStats() []*UserStats { ...@@ -979,8 +975,8 @@ func (x *ListAllUserStatsResponse) GetStats() []*UserStats {
type UserSetting struct { type UserSetting struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// The name of the user setting. // The name of the user setting.
// Format: users/{user}/settings/{setting}, {setting} is the key for the setting. // Format: users/{username}/settings/{setting}, {setting} is the key for the setting.
// For example, "users/123/settings/GENERAL" for general settings. // For example, "users/steven/settings/GENERAL" for general settings.
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
// Types that are valid to be assigned to Value: // Types that are valid to be assigned to Value:
// //
...@@ -2658,7 +2654,7 @@ const file_api_v1_user_service_proto_rawDesc = "" + ...@@ -2658,7 +2654,7 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"\x11memos.api.v1/UserR\x04name\"\x19\n" + "\x11memos.api.v1/UserR\x04name\"\x19\n" +
"\x17ListAllUserStatsRequest\"I\n" + "\x17ListAllUserStatsRequest\"I\n" +
"\x18ListAllUserStatsResponse\x12-\n" + "\x18ListAllUserStatsResponse\x12-\n" +
"\x05stats\x18\x01 \x03(\v2\x17.memos.api.v1.UserStatsR\x05stats\"\xb0\x04\n" + "\x05stats\x18\x01 \x03(\v2\x17.memos.api.v1.UserStatsR\x05stats\"\xb4\x04\n" +
"\vUserSetting\x12\x17\n" + "\vUserSetting\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12S\n" + "\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12S\n" +
"\x0fgeneral_setting\x18\x02 \x01(\v2(.memos.api.v1.UserSetting.GeneralSettingH\x00R\x0egeneralSetting\x12V\n" + "\x0fgeneral_setting\x18\x02 \x01(\v2(.memos.api.v1.UserSetting.GeneralSettingH\x00R\x0egeneralSetting\x12V\n" +
...@@ -2672,8 +2668,8 @@ const file_api_v1_user_service_proto_rawDesc = "" + ...@@ -2672,8 +2668,8 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"\x03Key\x12\x13\n" + "\x03Key\x12\x13\n" +
"\x0fKEY_UNSPECIFIED\x10\x00\x12\v\n" + "\x0fKEY_UNSPECIFIED\x10\x00\x12\v\n" +
"\aGENERAL\x10\x01\x12\f\n" + "\aGENERAL\x10\x01\x12\f\n" +
"\bWEBHOOKS\x10\x04:Y\xeaAV\n" + "\bWEBHOOKS\x10\x04:]\xeaAZ\n" +
"\x18memos.api.v1/UserSetting\x12\x1fusers/{user}/settings/{setting}*\fuserSettings2\vuserSettingB\a\n" + "\x18memos.api.v1/UserSetting\x12#users/{username}/settings/{setting}*\fuserSettings2\vuserSettingB\a\n" +
"\x05value\"M\n" + "\x05value\"M\n" +
"\x15GetUserSettingRequest\x124\n" + "\x15GetUserSettingRequest\x124\n" +
"\x04name\x18\x01 \x01(\tB \xe0A\x02\xfaA\x1a\n" + "\x04name\x18\x01 \x01(\tB \xe0A\x02\xfaA\x1a\n" +
......
...@@ -48,10 +48,8 @@ const ( ...@@ -48,10 +48,8 @@ const (
type UserServiceClient interface { type UserServiceClient interface {
// ListUsers returns a list of users. // ListUsers returns a list of users.
ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (*ListUsersResponse, error) ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (*ListUsersResponse, error)
// GetUser gets a user by ID or username. // GetUser gets a user by username.
// Supports both numeric IDs and username strings: // Format: users/{username} (e.g., users/steven)
// - users/{id} (e.g., users/101)
// - users/{username} (e.g., users/steven)
GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*User, error) GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*User, error)
// CreateUser creates a new user. // CreateUser creates a new user.
CreateUser(ctx context.Context, in *CreateUserRequest, opts ...grpc.CallOption) (*User, error) CreateUser(ctx context.Context, in *CreateUserRequest, opts ...grpc.CallOption) (*User, error)
...@@ -307,10 +305,8 @@ func (c *userServiceClient) DeleteUserNotification(ctx context.Context, in *Dele ...@@ -307,10 +305,8 @@ func (c *userServiceClient) DeleteUserNotification(ctx context.Context, in *Dele
type UserServiceServer interface { type UserServiceServer interface {
// ListUsers returns a list of users. // ListUsers returns a list of users.
ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error) ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error)
// GetUser gets a user by ID or username. // GetUser gets a user by username.
// Supports both numeric IDs and username strings: // Format: users/{username} (e.g., users/steven)
// - users/{id} (e.g., users/101)
// - users/{username} (e.g., users/steven)
GetUser(context.Context, *GetUserRequest) (*User, error) GetUser(context.Context, *GetUserRequest) (*User, error)
// CreateUser creates a new user. // CreateUser creates a new user.
CreateUser(context.Context, *CreateUserRequest) (*User, error) CreateUser(context.Context, *CreateUserRequest) (*User, error)
......
...@@ -1206,10 +1206,8 @@ paths: ...@@ -1206,10 +1206,8 @@ paths:
tags: tags:
- UserService - UserService
description: |- description: |-
GetUser gets a user by ID or username. GetUser gets a user by username.
Supports both numeric IDs and username strings: Format: users/{username} (e.g., users/steven)
- users/{id} (e.g., users/101)
- users/{username} (e.g., users/steven)
operationId: UserService_GetUser operationId: UserService_GetUser
parameters: parameters:
- name: user - name: user
...@@ -2939,7 +2937,7 @@ components: ...@@ -2939,7 +2937,7 @@ components:
type: string type: string
description: |- description: |-
The resource name of the shortcut. The resource name of the shortcut.
Format: users/{user}/shortcuts/{shortcut} Format: users/{username}/shortcuts/{shortcut}
title: title:
type: string type: string
description: The title of the shortcut. description: The title of the shortcut.
...@@ -3178,8 +3176,8 @@ components: ...@@ -3178,8 +3176,8 @@ components:
type: string type: string
description: |- description: |-
The name of the user setting. The name of the user setting.
Format: users/{user}/settings/{setting}, {setting} is the key for the setting. Format: users/{username}/settings/{setting}, {setting} is the key for the setting.
For example, "users/123/settings/GENERAL" for general settings. For example, "users/steven/settings/GENERAL" for general settings.
generalSetting: generalSetting:
$ref: '#/components/schemas/UserSetting_GeneralSetting' $ref: '#/components/schemas/UserSetting_GeneralSetting'
webhooksSetting: webhooksSetting:
......
...@@ -278,6 +278,14 @@ func (s *APIV1Service) ListMemos(ctx context.Context, request *v1pb.ListMemosReq ...@@ -278,6 +278,14 @@ func (s *APIV1Service) ListMemos(ctx context.Context, request *v1pb.ListMemosReq
if err != nil { if err != nil {
return nil, status.Errorf(codes.Internal, "failed to batch load memo relations") return nil, status.Errorf(codes.Internal, "failed to batch load memo relations")
} }
creatorIDs := make([]int32, 0, len(memos))
for _, memo := range memos {
creatorIDs = append(creatorIDs, memo.CreatorID)
}
creatorMap, err := s.listUsersByID(ctx, creatorIDs)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list memo creators: %v", err)
}
for _, memo := range memos { for _, memo := range memos {
memoName := fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID) memoName := fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID)
...@@ -285,7 +293,7 @@ func (s *APIV1Service) ListMemos(ctx context.Context, request *v1pb.ListMemosReq ...@@ -285,7 +293,7 @@ func (s *APIV1Service) ListMemos(ctx context.Context, request *v1pb.ListMemosReq
attachments := attachmentMap[memo.ID] attachments := attachmentMap[memo.ID]
relations := relationMap[memo.ID] relations := relationMap[memo.ID]
memoMessage, err := s.convertMemoFromStore(ctx, memo, reactions, attachments, relations) memoMessage, err := s.convertMemoFromStoreWithCreators(ctx, memo, reactions, attachments, relations, creatorMap)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "failed to convert memo") return nil, errors.Wrap(err, "failed to convert memo")
} }
...@@ -633,10 +641,14 @@ func (s *APIV1Service) CreateMemoComment(ctx context.Context, request *v1pb.Crea ...@@ -633,10 +641,14 @@ func (s *APIV1Service) CreateMemoComment(ctx context.Context, request *v1pb.Crea
if err != nil { if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create memo relation") return nil, status.Errorf(codes.Internal, "failed to create memo relation")
} }
creatorID, err := ExtractUserIDFromName(memoComment.Creator) creator, err := ResolveUserByName(ctx, s.Store, memoComment.Creator)
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid memo creator") return nil, status.Errorf(codes.InvalidArgument, "invalid memo creator")
} }
if creator == nil {
return nil, status.Errorf(codes.NotFound, "memo creator not found")
}
creatorID := creator.ID
if memoComment.Visibility != v1pb.Visibility_PRIVATE && creatorID != relatedMemo.CreatorID { if memoComment.Visibility != v1pb.Visibility_PRIVATE && creatorID != relatedMemo.CreatorID {
if _, err := s.Store.CreateInbox(ctx, &store.Inbox{ if _, err := s.Store.CreateInbox(ctx, &store.Inbox{
SenderID: creatorID, SenderID: creatorID,
...@@ -749,6 +761,14 @@ func (s *APIV1Service) ListMemoComments(ctx context.Context, request *v1pb.ListM ...@@ -749,6 +761,14 @@ func (s *APIV1Service) ListMemoComments(ctx context.Context, request *v1pb.ListM
if err != nil { if err != nil {
return nil, status.Errorf(codes.Internal, "failed to batch load memo relations") return nil, status.Errorf(codes.Internal, "failed to batch load memo relations")
} }
creatorIDs := make([]int32, 0, len(memos))
for _, memo := range memos {
creatorIDs = append(creatorIDs, memo.CreatorID)
}
creatorMap, err := s.listUsersByID(ctx, creatorIDs)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list memo creators: %v", err)
}
var memosResponse []*v1pb.Memo var memosResponse []*v1pb.Memo
for _, m := range memos { for _, m := range memos {
...@@ -757,7 +777,7 @@ func (s *APIV1Service) ListMemoComments(ctx context.Context, request *v1pb.ListM ...@@ -757,7 +777,7 @@ func (s *APIV1Service) ListMemoComments(ctx context.Context, request *v1pb.ListM
attachments := attachmentMap[m.ID] attachments := attachmentMap[m.ID]
relations := relationMap[m.ID] relations := relationMap[m.ID]
memoMessage, err := s.convertMemoFromStore(ctx, m, reactions, attachments, relations) memoMessage, err := s.convertMemoFromStoreWithCreators(ctx, m, reactions, attachments, relations, creatorMap)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "failed to convert memo") return nil, errors.Wrap(err, "failed to convert memo")
} }
...@@ -812,10 +832,14 @@ func (s *APIV1Service) DispatchMemoCommentCreatedWebhook(ctx context.Context, co ...@@ -812,10 +832,14 @@ func (s *APIV1Service) DispatchMemoCommentCreatedWebhook(ctx context.Context, co
} }
func (s *APIV1Service) dispatchMemoRelatedWebhook(ctx context.Context, memo *v1pb.Memo, activityType string) error { func (s *APIV1Service) dispatchMemoRelatedWebhook(ctx context.Context, memo *v1pb.Memo, activityType string) error {
creatorID, err := ExtractUserIDFromName(memo.Creator) creator, err := ResolveUserByName(ctx, s.Store, memo.Creator)
if err != nil { if err != nil {
return status.Errorf(codes.InvalidArgument, "invalid memo creator") return status.Errorf(codes.InvalidArgument, "invalid memo creator")
} }
if creator == nil {
return status.Errorf(codes.NotFound, "memo creator not found")
}
creatorID := creator.ID
webhooks, err := s.Store.GetUserWebhooks(ctx, creatorID) webhooks, err := s.Store.GetUserWebhooks(ctx, creatorID)
if err != nil { if err != nil {
return err return err
...@@ -835,12 +859,8 @@ func (s *APIV1Service) dispatchMemoRelatedWebhook(ctx context.Context, memo *v1p ...@@ -835,12 +859,8 @@ func (s *APIV1Service) dispatchMemoRelatedWebhook(ctx context.Context, memo *v1p
} }
func convertMemoToWebhookPayload(memo *v1pb.Memo) (*webhook.WebhookRequestPayload, error) { func convertMemoToWebhookPayload(memo *v1pb.Memo) (*webhook.WebhookRequestPayload, error) {
creatorID, err := ExtractUserIDFromName(memo.Creator)
if err != nil {
return nil, errors.Wrap(err, "invalid memo creator")
}
return &webhook.WebhookRequestPayload{ return &webhook.WebhookRequestPayload{
Creator: fmt.Sprintf("%s%d", UserNamePrefix, creatorID), Creator: memo.Creator,
Memo: memo, Memo: memo,
}, nil }, nil
} }
......
...@@ -14,6 +14,14 @@ import ( ...@@ -14,6 +14,14 @@ import (
) )
func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Memo, reactions []*store.Reaction, attachments []*store.Attachment, relations []*v1pb.MemoRelation) (*v1pb.Memo, error) { func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Memo, reactions []*store.Reaction, attachments []*store.Attachment, relations []*v1pb.MemoRelation) (*v1pb.Memo, error) {
creatorMap, err := s.listUsersByID(ctx, []int32{memo.CreatorID})
if err != nil {
return nil, errors.Wrap(err, "failed to list memo creators")
}
return s.convertMemoFromStoreWithCreators(ctx, memo, reactions, attachments, relations, creatorMap)
}
func (s *APIV1Service) convertMemoFromStoreWithCreators(ctx context.Context, memo *store.Memo, reactions []*store.Reaction, attachments []*store.Attachment, relations []*v1pb.MemoRelation, creatorMap map[int32]*store.User) (*v1pb.Memo, error) {
displayTs := memo.CreatedTs displayTs := memo.CreatedTs
instanceMemoRelatedSetting, err := s.Store.GetInstanceMemoRelatedSetting(ctx) instanceMemoRelatedSetting, err := s.Store.GetInstanceMemoRelatedSetting(ctx)
if err != nil { if err != nil {
...@@ -24,10 +32,14 @@ func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Mem ...@@ -24,10 +32,14 @@ func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Mem
} }
name := fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID) name := fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID)
creator := creatorMap[memo.CreatorID]
if creator == nil {
return nil, errors.New("memo creator not found")
}
memoMessage := &v1pb.Memo{ memoMessage := &v1pb.Memo{
Name: name, Name: name,
State: convertStateFromStore(memo.RowStatus), State: convertStateFromStore(memo.RowStatus),
Creator: fmt.Sprintf("%s%d", UserNamePrefix, memo.CreatorID), Creator: BuildUserName(creator.Username),
CreateTime: timestamppb.New(time.Unix(memo.CreatedTs, 0)), CreateTime: timestamppb.New(time.Unix(memo.CreatedTs, 0)),
UpdateTime: timestamppb.New(time.Unix(memo.UpdatedTs, 0)), UpdateTime: timestamppb.New(time.Unix(memo.UpdatedTs, 0)),
DisplayTime: timestamppb.New(time.Unix(displayTs, 0)), DisplayTime: timestamppb.New(time.Unix(displayTs, 0)),
...@@ -48,7 +60,10 @@ func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Mem ...@@ -48,7 +60,10 @@ func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Mem
memoMessage.Reactions = []*v1pb.Reaction{} memoMessage.Reactions = []*v1pb.Reaction{}
for _, reaction := range reactions { for _, reaction := range reactions {
reactionResponse := convertReactionFromStore(reaction) reactionResponse, err := s.convertReactionFromStore(ctx, reaction)
if err != nil {
return nil, errors.Wrap(err, "failed to convert reaction")
}
memoMessage.Reactions = append(memoMessage.Reactions, reactionResponse) memoMessage.Reactions = append(memoMessage.Reactions, reactionResponse)
} }
......
...@@ -53,7 +53,10 @@ func (s *APIV1Service) ListMemoReactions(ctx context.Context, request *v1pb.List ...@@ -53,7 +53,10 @@ func (s *APIV1Service) ListMemoReactions(ctx context.Context, request *v1pb.List
Reactions: []*v1pb.Reaction{}, Reactions: []*v1pb.Reaction{},
} }
for _, reaction := range reactions { for _, reaction := range reactions {
reactionMessage := convertReactionFromStore(reaction) reactionMessage, err := s.convertReactionFromStore(ctx, reaction)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to convert reaction")
}
response.Reactions = append(response.Reactions, reactionMessage) response.Reactions = append(response.Reactions, reactionMessage)
} }
return response, nil return response, nil
...@@ -95,7 +98,10 @@ func (s *APIV1Service) UpsertMemoReaction(ctx context.Context, request *v1pb.Ups ...@@ -95,7 +98,10 @@ func (s *APIV1Service) UpsertMemoReaction(ctx context.Context, request *v1pb.Ups
return nil, status.Errorf(codes.Internal, "failed to upsert reaction") return nil, status.Errorf(codes.Internal, "failed to upsert reaction")
} }
reactionMessage := convertReactionFromStore(reaction) reactionMessage, err := s.convertReactionFromStore(ctx, reaction)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to convert reaction")
}
// Broadcast live refresh event (reaction belongs to a memo). // Broadcast live refresh event (reaction belongs to a memo).
s.SSEHub.Broadcast(&SSEEvent{ s.SSEHub.Broadcast(&SSEEvent{
...@@ -151,15 +157,22 @@ func (s *APIV1Service) DeleteMemoReaction(ctx context.Context, request *v1pb.Del ...@@ -151,15 +157,22 @@ func (s *APIV1Service) DeleteMemoReaction(ctx context.Context, request *v1pb.Del
return &emptypb.Empty{}, nil return &emptypb.Empty{}, nil
} }
func convertReactionFromStore(reaction *store.Reaction) *v1pb.Reaction { func (s *APIV1Service) convertReactionFromStore(ctx context.Context, reaction *store.Reaction) (*v1pb.Reaction, error) {
reactionUID := fmt.Sprintf("%d", reaction.ID) reactionUID := fmt.Sprintf("%d", reaction.ID)
creator, err := s.Store.GetUser(ctx, &store.FindUser{ID: &reaction.CreatorID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get reaction creator")
}
if creator == nil {
return nil, status.Errorf(codes.NotFound, "reaction creator not found")
}
// Generate nested resource name: memos/{memo}/reactions/{reaction} // Generate nested resource name: memos/{memo}/reactions/{reaction}
// reaction.ContentID already contains "memos/{memo}" // reaction.ContentID already contains "memos/{memo}"
return &v1pb.Reaction{ return &v1pb.Reaction{
Name: fmt.Sprintf("%s/%s%s", reaction.ContentID, ReactionNamePrefix, reactionUID), Name: fmt.Sprintf("%s/%s%s", reaction.ContentID, ReactionNamePrefix, reactionUID),
Creator: fmt.Sprintf("%s%d", UserNamePrefix, reaction.CreatorID), Creator: BuildUserName(creator.Username),
ContentId: reaction.ContentID, ContentId: reaction.ContentID,
ReactionType: reaction.ReactionType, ReactionType: reaction.ReactionType,
CreateTime: timestamppb.New(time.Unix(reaction.CreatedTs, 0)), CreateTime: timestamppb.New(time.Unix(reaction.CreatedTs, 0)),
} }, nil
} }
...@@ -77,17 +77,6 @@ func ExtractUserIDFromName(name string) (int32, error) { ...@@ -77,17 +77,6 @@ func ExtractUserIDFromName(name string) (int32, error) {
return id, nil return id, nil
} }
// extractUserIdentifierFromName extracts the identifier (ID or username) from a user resource name.
// Supports: "users/101" or "users/steven"
// Returns the identifier string (e.g., "101" or "steven").
func extractUserIdentifierFromName(name string) string {
tokens, err := GetNameParentTokens(name, UserNamePrefix)
if err != nil || len(tokens) == 0 {
return ""
}
return tokens[0]
}
// ExtractMemoUIDFromName returns the memo UID from a resource name. // ExtractMemoUIDFromName returns the memo UID from a resource name.
// e.g., "memos/uuid" -> "uuid". // e.g., "memos/uuid" -> "uuid".
func ExtractMemoUIDFromName(name string) (string, error) { func ExtractMemoUIDFromName(name string) (string, error) {
......
...@@ -17,37 +17,44 @@ import ( ...@@ -17,37 +17,44 @@ import (
"github.com/usememos/memos/store" "github.com/usememos/memos/store"
) )
// Helper function to extract user ID and shortcut ID from shortcut resource name. // Helper function to extract user and shortcut ID from shortcut resource name.
// Format: users/{user}/shortcuts/{shortcut}. // Format: users/{user}/shortcuts/{shortcut}.
func extractUserAndShortcutIDFromName(name string) (int32, string, error) { func (s *APIV1Service) extractUserAndShortcutIDFromName(ctx context.Context, name string) (*store.User, string, error) {
parts := strings.Split(name, "/") parts := strings.Split(name, "/")
if len(parts) != 4 || parts[0] != "users" || parts[2] != "shortcuts" { if len(parts) != 4 || parts[0] != "users" || parts[2] != "shortcuts" {
return 0, "", errors.Errorf("invalid shortcut name format: %s", name) return nil, "", errors.Errorf("invalid shortcut name format: %s", name)
} }
userID, err := util.ConvertStringToInt32(parts[1]) user, err := ResolveUserByName(ctx, s.Store, BuildUserName(parts[1]))
if err != nil { if err != nil {
return 0, "", errors.Errorf("invalid user ID %q", parts[1]) return nil, "", err
}
if user == nil {
return nil, "", errors.Errorf("user not found: %s", parts[1])
} }
shortcutID := parts[3] shortcutID := parts[3]
if shortcutID == "" { if shortcutID == "" {
return 0, "", errors.Errorf("empty shortcut ID in name: %s", name) return nil, "", errors.Errorf("empty shortcut ID in name: %s", name)
} }
return userID, shortcutID, nil return user, shortcutID, nil
} }
// Helper function to construct shortcut resource name. // Helper function to construct shortcut resource name.
func constructShortcutName(userID int32, shortcutID string) string { func constructShortcutName(username string, shortcutID string) string {
return fmt.Sprintf("users/%d/shortcuts/%s", userID, shortcutID) return fmt.Sprintf("%s/shortcuts/%s", BuildUserName(username), shortcutID)
} }
func (s *APIV1Service) ListShortcuts(ctx context.Context, request *v1pb.ListShortcutsRequest) (*v1pb.ListShortcutsResponse, error) { func (s *APIV1Service) ListShortcuts(ctx context.Context, request *v1pb.ListShortcutsRequest) (*v1pb.ListShortcutsResponse, error) {
userID, err := ExtractUserIDFromName(request.Parent) user, err := ResolveUserByName(ctx, s.Store, request.Parent)
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
} }
if user == nil {
return nil, status.Errorf(codes.NotFound, "user not found")
}
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx) currentUser, err := s.fetchCurrentUser(ctx)
if err != nil { if err != nil {
...@@ -74,7 +81,7 @@ func (s *APIV1Service) ListShortcuts(ctx context.Context, request *v1pb.ListShor ...@@ -74,7 +81,7 @@ func (s *APIV1Service) ListShortcuts(ctx context.Context, request *v1pb.ListShor
shortcuts := []*v1pb.Shortcut{} shortcuts := []*v1pb.Shortcut{}
for _, shortcut := range shortcutsUserSetting.GetShortcuts() { for _, shortcut := range shortcutsUserSetting.GetShortcuts() {
shortcuts = append(shortcuts, &v1pb.Shortcut{ shortcuts = append(shortcuts, &v1pb.Shortcut{
Name: constructShortcutName(userID, shortcut.GetId()), Name: constructShortcutName(user.Username, shortcut.GetId()),
Title: shortcut.GetTitle(), Title: shortcut.GetTitle(),
Filter: shortcut.GetFilter(), Filter: shortcut.GetFilter(),
}) })
...@@ -86,10 +93,11 @@ func (s *APIV1Service) ListShortcuts(ctx context.Context, request *v1pb.ListShor ...@@ -86,10 +93,11 @@ func (s *APIV1Service) ListShortcuts(ctx context.Context, request *v1pb.ListShor
} }
func (s *APIV1Service) GetShortcut(ctx context.Context, request *v1pb.GetShortcutRequest) (*v1pb.Shortcut, error) { func (s *APIV1Service) GetShortcut(ctx context.Context, request *v1pb.GetShortcutRequest) (*v1pb.Shortcut, error) {
userID, shortcutID, err := extractUserAndShortcutIDFromName(request.Name) user, shortcutID, err := s.extractUserAndShortcutIDFromName(ctx, request.Name)
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid shortcut name: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid shortcut name: %v", err)
} }
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx) currentUser, err := s.fetchCurrentUser(ctx)
if err != nil { if err != nil {
...@@ -114,7 +122,7 @@ func (s *APIV1Service) GetShortcut(ctx context.Context, request *v1pb.GetShortcu ...@@ -114,7 +122,7 @@ func (s *APIV1Service) GetShortcut(ctx context.Context, request *v1pb.GetShortcu
for _, shortcut := range shortcutsUserSetting.GetShortcuts() { for _, shortcut := range shortcutsUserSetting.GetShortcuts() {
if shortcut.GetId() == shortcutID { if shortcut.GetId() == shortcutID {
return &v1pb.Shortcut{ return &v1pb.Shortcut{
Name: constructShortcutName(userID, shortcut.GetId()), Name: constructShortcutName(user.Username, shortcut.GetId()),
Title: shortcut.GetTitle(), Title: shortcut.GetTitle(),
Filter: shortcut.GetFilter(), Filter: shortcut.GetFilter(),
}, nil }, nil
...@@ -125,10 +133,14 @@ func (s *APIV1Service) GetShortcut(ctx context.Context, request *v1pb.GetShortcu ...@@ -125,10 +133,14 @@ func (s *APIV1Service) GetShortcut(ctx context.Context, request *v1pb.GetShortcu
} }
func (s *APIV1Service) CreateShortcut(ctx context.Context, request *v1pb.CreateShortcutRequest) (*v1pb.Shortcut, error) { func (s *APIV1Service) CreateShortcut(ctx context.Context, request *v1pb.CreateShortcutRequest) (*v1pb.Shortcut, error) {
userID, err := ExtractUserIDFromName(request.Parent) user, err := ResolveUserByName(ctx, s.Store, request.Parent)
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
} }
if user == nil {
return nil, status.Errorf(codes.NotFound, "user not found")
}
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx) currentUser, err := s.fetchCurrentUser(ctx)
if err != nil { if err != nil {
...@@ -151,7 +163,7 @@ func (s *APIV1Service) CreateShortcut(ctx context.Context, request *v1pb.CreateS ...@@ -151,7 +163,7 @@ func (s *APIV1Service) CreateShortcut(ctx context.Context, request *v1pb.CreateS
} }
if request.ValidateOnly { if request.ValidateOnly {
return &v1pb.Shortcut{ return &v1pb.Shortcut{
Name: constructShortcutName(userID, newShortcut.GetId()), Name: constructShortcutName(user.Username, newShortcut.GetId()),
Title: newShortcut.GetTitle(), Title: newShortcut.GetTitle(),
Filter: newShortcut.GetFilter(), Filter: newShortcut.GetFilter(),
}, nil }, nil
...@@ -190,17 +202,18 @@ func (s *APIV1Service) CreateShortcut(ctx context.Context, request *v1pb.CreateS ...@@ -190,17 +202,18 @@ func (s *APIV1Service) CreateShortcut(ctx context.Context, request *v1pb.CreateS
} }
return &v1pb.Shortcut{ return &v1pb.Shortcut{
Name: constructShortcutName(userID, newShortcut.GetId()), Name: constructShortcutName(user.Username, newShortcut.GetId()),
Title: newShortcut.GetTitle(), Title: newShortcut.GetTitle(),
Filter: newShortcut.GetFilter(), Filter: newShortcut.GetFilter(),
}, nil }, nil
} }
func (s *APIV1Service) UpdateShortcut(ctx context.Context, request *v1pb.UpdateShortcutRequest) (*v1pb.Shortcut, error) { func (s *APIV1Service) UpdateShortcut(ctx context.Context, request *v1pb.UpdateShortcutRequest) (*v1pb.Shortcut, error) {
userID, shortcutID, err := extractUserAndShortcutIDFromName(request.Shortcut.Name) user, shortcutID, err := s.extractUserAndShortcutIDFromName(ctx, request.Shortcut.Name)
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid shortcut name: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid shortcut name: %v", err)
} }
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx) currentUser, err := s.fetchCurrentUser(ctx)
if err != nil { if err != nil {
...@@ -262,17 +275,18 @@ func (s *APIV1Service) UpdateShortcut(ctx context.Context, request *v1pb.UpdateS ...@@ -262,17 +275,18 @@ func (s *APIV1Service) UpdateShortcut(ctx context.Context, request *v1pb.UpdateS
} }
return &v1pb.Shortcut{ return &v1pb.Shortcut{
Name: constructShortcutName(userID, foundShortcut.GetId()), Name: constructShortcutName(user.Username, foundShortcut.GetId()),
Title: foundShortcut.GetTitle(), Title: foundShortcut.GetTitle(),
Filter: foundShortcut.GetFilter(), Filter: foundShortcut.GetFilter(),
}, nil }, nil
} }
func (s *APIV1Service) DeleteShortcut(ctx context.Context, request *v1pb.DeleteShortcutRequest) (*emptypb.Empty, error) { func (s *APIV1Service) DeleteShortcut(ctx context.Context, request *v1pb.DeleteShortcutRequest) (*emptypb.Empty, error) {
userID, shortcutID, err := extractUserAndShortcutIDFromName(request.Name) user, shortcutID, err := s.extractUserAndShortcutIDFromName(ctx, request.Name)
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid shortcut name: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid shortcut name: %v", err)
} }
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx) currentUser, err := s.fetchCurrentUser(ctx)
if err != nil { if err != nil {
......
...@@ -154,7 +154,7 @@ func TestListMemos(t *testing.T) { ...@@ -154,7 +154,7 @@ func TestListMemos(t *testing.T) {
memoOneRes := memos.Memos[memoOneResIdx] memoOneRes := memos.Memos[memoOneResIdx]
require.NotNil(t, memoOneRes) require.NotNil(t, memoOneRes)
require.Equal(t, fmt.Sprintf("users/%d", userOne.ID), memoOneRes.GetCreator()) require.Equal(t, fmt.Sprintf("users/%s", userOne.Username), memoOneRes.GetCreator())
require.Equal(t, apiv1.Visibility_PROTECTED, memoOneRes.GetVisibility()) require.Equal(t, apiv1.Visibility_PROTECTED, memoOneRes.GetVisibility())
require.Equal(t, memoOne.Content, memoOneRes.GetContent()) require.Equal(t, memoOne.Content, memoOneRes.GetContent())
require.Equal(t, memoOne.Content[:64]+"...", memoOneRes.GetSnippet(), "memoOne's content is snipped past the 64 char limit") require.Equal(t, memoOne.Content[:64]+"...", memoOneRes.GetSnippet(), "memoOne's content is snipped past the 64 char limit")
...@@ -202,7 +202,7 @@ func TestListMemos(t *testing.T) { ...@@ -202,7 +202,7 @@ func TestListMemos(t *testing.T) {
memoTwoRes := memos.Memos[memoTwoResIdx] memoTwoRes := memos.Memos[memoTwoResIdx]
require.NotNil(t, memoTwoRes) require.NotNil(t, memoTwoRes)
require.Equal(t, fmt.Sprintf("users/%d", userTwo.ID), memoTwoRes.GetCreator()) require.Equal(t, fmt.Sprintf("users/%s", userTwo.Username), memoTwoRes.GetCreator())
require.Equal(t, apiv1.Visibility_PROTECTED, memoTwoRes.GetVisibility()) require.Equal(t, apiv1.Visibility_PROTECTED, memoTwoRes.GetVisibility())
require.Equal(t, memoTwo.Content, memoTwoRes.GetContent()) require.Equal(t, memoTwo.Content, memoTwoRes.GetContent())
require.Empty(t, memoTwoRes.Attachments) require.Empty(t, memoTwoRes.Attachments)
...@@ -227,7 +227,7 @@ func TestListMemos(t *testing.T) { ...@@ -227,7 +227,7 @@ func TestListMemos(t *testing.T) {
memoThreeRes := memos.Memos[memoThreeResIdx] memoThreeRes := memos.Memos[memoThreeResIdx]
require.NotNil(t, memoThreeRes) require.NotNil(t, memoThreeRes)
require.Equal(t, fmt.Sprintf("users/%d", userOne.ID), memoThreeRes.GetCreator()) require.Equal(t, fmt.Sprintf("users/%s", userOne.Username), memoThreeRes.GetCreator())
require.Equal(t, apiv1.Visibility_PROTECTED, memoThreeRes.GetVisibility()) require.Equal(t, apiv1.Visibility_PROTECTED, memoThreeRes.GetVisibility())
require.Equal(t, memoThree.Content, memoThreeRes.GetContent()) require.Equal(t, memoThree.Content, memoThreeRes.GetContent())
require.Empty(t, memoThreeRes.Attachments) require.Empty(t, memoThreeRes.Attachments)
...@@ -237,7 +237,7 @@ func TestListMemos(t *testing.T) { ...@@ -237,7 +237,7 @@ func TestListMemos(t *testing.T) {
// verify memoThree's reactions // verify memoThree's reactions
require.Len(t, memoThreeRes.Reactions, 2) require.Len(t, memoThreeRes.Reactions, 2)
// userOne's reaction // userOne's reaction
userOneReactionIdx := slices.IndexFunc(memoThreeRes.Reactions, func(r *apiv1.Reaction) bool { return r.GetCreator() == fmt.Sprintf("users/%d", userOne.ID) }) userOneReactionIdx := slices.IndexFunc(memoThreeRes.Reactions, func(r *apiv1.Reaction) bool { return r.GetCreator() == fmt.Sprintf("users/%s", userOne.Username) })
require.NotEqual(t, userOneReactionIdx, -1) require.NotEqual(t, userOneReactionIdx, -1)
userOneReaction := memoThreeRes.Reactions[userOneReactionIdx] userOneReaction := memoThreeRes.Reactions[userOneReactionIdx]
...@@ -245,7 +245,7 @@ func TestListMemos(t *testing.T) { ...@@ -245,7 +245,7 @@ func TestListMemos(t *testing.T) {
require.Equal(t, "❤️", userOneReaction.ReactionType) require.Equal(t, "❤️", userOneReaction.ReactionType)
// userTwo's reaction // userTwo's reaction
userTwoReactionIdx := slices.IndexFunc(memoThreeRes.Reactions, func(r *apiv1.Reaction) bool { return r.GetCreator() == fmt.Sprintf("users/%d", userTwo.ID) }) userTwoReactionIdx := slices.IndexFunc(memoThreeRes.Reactions, func(r *apiv1.Reaction) bool { return r.GetCreator() == fmt.Sprintf("users/%s", userTwo.Username) })
require.NotEqual(t, userTwoReactionIdx, -1) require.NotEqual(t, userTwoReactionIdx, -1)
userTwoReaction := memoThreeRes.Reactions[userTwoReactionIdx] userTwoReaction := memoThreeRes.Reactions[userTwoReactionIdx]
......
...@@ -41,6 +41,7 @@ func TestDeleteMemoReaction(t *testing.T) { ...@@ -41,6 +41,7 @@ func TestDeleteMemoReaction(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, reaction) require.NotNil(t, reaction)
require.Equal(t, "users/user", reaction.Creator)
// Delete reaction - should succeed // Delete reaction - should succeed
_, err = ts.Service.DeleteMemoReaction(userCtx, &apiv1.DeleteMemoReactionRequest{ _, err = ts.Service.DeleteMemoReaction(userCtx, &apiv1.DeleteMemoReactionRequest{
......
...@@ -27,7 +27,7 @@ func TestListShortcuts(t *testing.T) { ...@@ -27,7 +27,7 @@ func TestListShortcuts(t *testing.T) {
// List shortcuts (should be empty initially) // List shortcuts (should be empty initially)
req := &v1pb.ListShortcutsRequest{ req := &v1pb.ListShortcutsRequest{
Parent: fmt.Sprintf("users/%d", user.ID), Parent: fmt.Sprintf("users/%s", user.Username),
} }
resp, err := ts.Service.ListShortcuts(userCtx, req) resp, err := ts.Service.ListShortcuts(userCtx, req)
...@@ -50,7 +50,7 @@ func TestListShortcuts(t *testing.T) { ...@@ -50,7 +50,7 @@ func TestListShortcuts(t *testing.T) {
userCtx := ts.CreateUserContext(ctx, user1.ID) userCtx := ts.CreateUserContext(ctx, user1.ID)
req := &v1pb.ListShortcutsRequest{ req := &v1pb.ListShortcutsRequest{
Parent: fmt.Sprintf("users/%d", user2.ID), Parent: fmt.Sprintf("users/%s", user2.Username),
} }
_, err = ts.Service.ListShortcuts(userCtx, req) _, err = ts.Service.ListShortcuts(userCtx, req)
...@@ -82,14 +82,33 @@ func TestListShortcuts(t *testing.T) { ...@@ -82,14 +82,33 @@ func TestListShortcuts(t *testing.T) {
ts := NewTestService(t) ts := NewTestService(t)
defer ts.Cleanup() defer ts.Cleanup()
_, err := ts.CreateRegularUser(ctx, "testuser")
require.NoError(t, err)
req := &v1pb.ListShortcutsRequest{ req := &v1pb.ListShortcutsRequest{
Parent: "users/1", Parent: "users/testuser",
} }
_, err := ts.Service.ListShortcuts(ctx, req) _, err = ts.Service.ListShortcuts(ctx, req)
require.Error(t, err) require.Error(t, err)
require.Contains(t, err.Error(), "permission denied") require.Contains(t, err.Error(), "permission denied")
}) })
t.Run("ListShortcuts rejects numeric parent", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
user, err := ts.CreateRegularUser(ctx, "testuser")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, user.ID)
_, err = ts.Service.ListShortcuts(userCtx, &v1pb.ListShortcutsRequest{
Parent: "users/1",
})
require.Error(t, err)
require.Contains(t, err.Error(), "invalid user name")
})
} }
func TestGetShortcut(t *testing.T) { func TestGetShortcut(t *testing.T) {
...@@ -108,7 +127,7 @@ func TestGetShortcut(t *testing.T) { ...@@ -108,7 +127,7 @@ func TestGetShortcut(t *testing.T) {
// First create a shortcut // First create a shortcut
createReq := &v1pb.CreateShortcutRequest{ createReq := &v1pb.CreateShortcutRequest{
Parent: fmt.Sprintf("users/%d", user.ID), Parent: fmt.Sprintf("users/%s", user.Username),
Shortcut: &v1pb.Shortcut{ Shortcut: &v1pb.Shortcut{
Title: "Test Shortcut", Title: "Test Shortcut",
Filter: "tag in [\"test\"]", Filter: "tag in [\"test\"]",
...@@ -144,7 +163,7 @@ func TestGetShortcut(t *testing.T) { ...@@ -144,7 +163,7 @@ func TestGetShortcut(t *testing.T) {
// Create shortcut as user1 // Create shortcut as user1
user1Ctx := ts.CreateUserContext(ctx, user1.ID) user1Ctx := ts.CreateUserContext(ctx, user1.ID)
createReq := &v1pb.CreateShortcutRequest{ createReq := &v1pb.CreateShortcutRequest{
Parent: fmt.Sprintf("users/%d", user1.ID), Parent: fmt.Sprintf("users/%s", user1.Username),
Shortcut: &v1pb.Shortcut{ Shortcut: &v1pb.Shortcut{
Title: "User1 Shortcut", Title: "User1 Shortcut",
Filter: "tag in [\"user1\"]", Filter: "tag in [\"user1\"]",
...@@ -197,7 +216,7 @@ func TestGetShortcut(t *testing.T) { ...@@ -197,7 +216,7 @@ func TestGetShortcut(t *testing.T) {
userCtx := ts.CreateUserContext(ctx, user.ID) userCtx := ts.CreateUserContext(ctx, user.ID)
req := &v1pb.GetShortcutRequest{ req := &v1pb.GetShortcutRequest{
Name: fmt.Sprintf("users/%d", user.ID) + "/shortcuts/nonexistent", Name: fmt.Sprintf("users/%s", user.Username) + "/shortcuts/nonexistent",
} }
_, err = ts.Service.GetShortcut(userCtx, req) _, err = ts.Service.GetShortcut(userCtx, req)
...@@ -221,7 +240,7 @@ func TestCreateShortcut(t *testing.T) { ...@@ -221,7 +240,7 @@ func TestCreateShortcut(t *testing.T) {
userCtx := ts.CreateUserContext(ctx, user.ID) userCtx := ts.CreateUserContext(ctx, user.ID)
req := &v1pb.CreateShortcutRequest{ req := &v1pb.CreateShortcutRequest{
Parent: fmt.Sprintf("users/%d", user.ID), Parent: fmt.Sprintf("users/%s", user.Username),
Shortcut: &v1pb.Shortcut{ Shortcut: &v1pb.Shortcut{
Title: "My Shortcut", Title: "My Shortcut",
Filter: "tag in [\"important\"]", Filter: "tag in [\"important\"]",
...@@ -233,11 +252,11 @@ func TestCreateShortcut(t *testing.T) { ...@@ -233,11 +252,11 @@ func TestCreateShortcut(t *testing.T) {
require.NotNil(t, resp) require.NotNil(t, resp)
require.Equal(t, "My Shortcut", resp.Title) require.Equal(t, "My Shortcut", resp.Title)
require.Equal(t, "tag in [\"important\"]", resp.Filter) require.Equal(t, "tag in [\"important\"]", resp.Filter)
require.Contains(t, resp.Name, fmt.Sprintf("users/%d/shortcuts/", user.ID)) require.Contains(t, resp.Name, fmt.Sprintf("users/%s/shortcuts/", user.Username))
// Verify the shortcut was created by listing // Verify the shortcut was created by listing
listReq := &v1pb.ListShortcutsRequest{ listReq := &v1pb.ListShortcutsRequest{
Parent: fmt.Sprintf("users/%d", user.ID), Parent: fmt.Sprintf("users/%s", user.Username),
} }
listResp, err := ts.Service.ListShortcuts(userCtx, listReq) listResp, err := ts.Service.ListShortcuts(userCtx, listReq)
...@@ -260,7 +279,7 @@ func TestCreateShortcut(t *testing.T) { ...@@ -260,7 +279,7 @@ func TestCreateShortcut(t *testing.T) {
userCtx := ts.CreateUserContext(ctx, user1.ID) userCtx := ts.CreateUserContext(ctx, user1.ID)
req := &v1pb.CreateShortcutRequest{ req := &v1pb.CreateShortcutRequest{
Parent: fmt.Sprintf("users/%d", user2.ID), Parent: fmt.Sprintf("users/%s", user2.Username),
Shortcut: &v1pb.Shortcut{ Shortcut: &v1pb.Shortcut{
Title: "Forbidden Shortcut", Title: "Forbidden Shortcut",
Filter: "tag in [\"forbidden\"]", Filter: "tag in [\"forbidden\"]",
...@@ -308,7 +327,7 @@ func TestCreateShortcut(t *testing.T) { ...@@ -308,7 +327,7 @@ func TestCreateShortcut(t *testing.T) {
userCtx := ts.CreateUserContext(ctx, user.ID) userCtx := ts.CreateUserContext(ctx, user.ID)
req := &v1pb.CreateShortcutRequest{ req := &v1pb.CreateShortcutRequest{
Parent: fmt.Sprintf("users/%d", user.ID), Parent: fmt.Sprintf("users/%s", user.Username),
Shortcut: &v1pb.Shortcut{ Shortcut: &v1pb.Shortcut{
Title: "Invalid Filter Shortcut", Title: "Invalid Filter Shortcut",
Filter: "invalid||filter))syntax", Filter: "invalid||filter))syntax",
...@@ -332,7 +351,7 @@ func TestCreateShortcut(t *testing.T) { ...@@ -332,7 +351,7 @@ func TestCreateShortcut(t *testing.T) {
userCtx := ts.CreateUserContext(ctx, user.ID) userCtx := ts.CreateUserContext(ctx, user.ID)
req := &v1pb.CreateShortcutRequest{ req := &v1pb.CreateShortcutRequest{
Parent: fmt.Sprintf("users/%d", user.ID), Parent: fmt.Sprintf("users/%s", user.Username),
Shortcut: &v1pb.Shortcut{ Shortcut: &v1pb.Shortcut{
Filter: "tag in [\"test\"]", Filter: "tag in [\"test\"]",
}, },
...@@ -360,7 +379,7 @@ func TestUpdateShortcut(t *testing.T) { ...@@ -360,7 +379,7 @@ func TestUpdateShortcut(t *testing.T) {
// Create a shortcut first // Create a shortcut first
createReq := &v1pb.CreateShortcutRequest{ createReq := &v1pb.CreateShortcutRequest{
Parent: fmt.Sprintf("users/%d", user.ID), Parent: fmt.Sprintf("users/%s", user.Username),
Shortcut: &v1pb.Shortcut{ Shortcut: &v1pb.Shortcut{
Title: "Original Title", Title: "Original Title",
Filter: "tag in [\"original\"]", Filter: "tag in [\"original\"]",
...@@ -403,7 +422,7 @@ func TestUpdateShortcut(t *testing.T) { ...@@ -403,7 +422,7 @@ func TestUpdateShortcut(t *testing.T) {
// Create shortcut as user1 // Create shortcut as user1
user1Ctx := ts.CreateUserContext(ctx, user1.ID) user1Ctx := ts.CreateUserContext(ctx, user1.ID)
createReq := &v1pb.CreateShortcutRequest{ createReq := &v1pb.CreateShortcutRequest{
Parent: fmt.Sprintf("users/%d", user1.ID), Parent: fmt.Sprintf("users/%s", user1.Username),
Shortcut: &v1pb.Shortcut{ Shortcut: &v1pb.Shortcut{
Title: "User1 Shortcut", Title: "User1 Shortcut",
Filter: "tag in [\"user1\"]", Filter: "tag in [\"user1\"]",
...@@ -442,7 +461,7 @@ func TestUpdateShortcut(t *testing.T) { ...@@ -442,7 +461,7 @@ func TestUpdateShortcut(t *testing.T) {
req := &v1pb.UpdateShortcutRequest{ req := &v1pb.UpdateShortcutRequest{
Shortcut: &v1pb.Shortcut{ Shortcut: &v1pb.Shortcut{
Name: fmt.Sprintf("users/%d/shortcuts/test", user.ID), Name: fmt.Sprintf("users/%s/shortcuts/test", user.Username),
Title: "Updated Title", Title: "Updated Title",
}, },
} }
...@@ -484,7 +503,7 @@ func TestUpdateShortcut(t *testing.T) { ...@@ -484,7 +503,7 @@ func TestUpdateShortcut(t *testing.T) {
// Create a shortcut first // Create a shortcut first
createReq := &v1pb.CreateShortcutRequest{ createReq := &v1pb.CreateShortcutRequest{
Parent: fmt.Sprintf("users/%d", user.ID), Parent: fmt.Sprintf("users/%s", user.Username),
Shortcut: &v1pb.Shortcut{ Shortcut: &v1pb.Shortcut{
Title: "Test Shortcut", Title: "Test Shortcut",
Filter: "tag in [\"test\"]", Filter: "tag in [\"test\"]",
...@@ -527,7 +546,7 @@ func TestDeleteShortcut(t *testing.T) { ...@@ -527,7 +546,7 @@ func TestDeleteShortcut(t *testing.T) {
// Create a shortcut first // Create a shortcut first
createReq := &v1pb.CreateShortcutRequest{ createReq := &v1pb.CreateShortcutRequest{
Parent: fmt.Sprintf("users/%d", user.ID), Parent: fmt.Sprintf("users/%s", user.Username),
Shortcut: &v1pb.Shortcut{ Shortcut: &v1pb.Shortcut{
Title: "Shortcut to Delete", Title: "Shortcut to Delete",
Filter: "tag in [\"delete\"]", Filter: "tag in [\"delete\"]",
...@@ -547,7 +566,7 @@ func TestDeleteShortcut(t *testing.T) { ...@@ -547,7 +566,7 @@ func TestDeleteShortcut(t *testing.T) {
// Verify deletion by listing shortcuts // Verify deletion by listing shortcuts
listReq := &v1pb.ListShortcutsRequest{ listReq := &v1pb.ListShortcutsRequest{
Parent: fmt.Sprintf("users/%d", user.ID), Parent: fmt.Sprintf("users/%s", user.Username),
} }
listResp, err := ts.Service.ListShortcuts(userCtx, listReq) listResp, err := ts.Service.ListShortcuts(userCtx, listReq)
...@@ -577,7 +596,7 @@ func TestDeleteShortcut(t *testing.T) { ...@@ -577,7 +596,7 @@ func TestDeleteShortcut(t *testing.T) {
// Create shortcut as user1 // Create shortcut as user1
user1Ctx := ts.CreateUserContext(ctx, user1.ID) user1Ctx := ts.CreateUserContext(ctx, user1.ID)
createReq := &v1pb.CreateShortcutRequest{ createReq := &v1pb.CreateShortcutRequest{
Parent: fmt.Sprintf("users/%d", user1.ID), Parent: fmt.Sprintf("users/%s", user1.Username),
Shortcut: &v1pb.Shortcut{ Shortcut: &v1pb.Shortcut{
Title: "User1 Shortcut", Title: "User1 Shortcut",
Filter: "tag in [\"user1\"]", Filter: "tag in [\"user1\"]",
...@@ -623,7 +642,7 @@ func TestDeleteShortcut(t *testing.T) { ...@@ -623,7 +642,7 @@ func TestDeleteShortcut(t *testing.T) {
userCtx := ts.CreateUserContext(ctx, user.ID) userCtx := ts.CreateUserContext(ctx, user.ID)
req := &v1pb.DeleteShortcutRequest{ req := &v1pb.DeleteShortcutRequest{
Name: fmt.Sprintf("users/%d", user.ID) + "/shortcuts/nonexistent", Name: fmt.Sprintf("users/%s", user.Username) + "/shortcuts/nonexistent",
} }
_, err = ts.Service.DeleteShortcut(userCtx, req) _, err = ts.Service.DeleteShortcut(userCtx, req)
...@@ -660,7 +679,7 @@ func TestShortcutFiltering(t *testing.T) { ...@@ -660,7 +679,7 @@ func TestShortcutFiltering(t *testing.T) {
for i, filter := range validFilters { for i, filter := range validFilters {
req := &v1pb.CreateShortcutRequest{ req := &v1pb.CreateShortcutRequest{
Parent: fmt.Sprintf("users/%d", user.ID), Parent: fmt.Sprintf("users/%s", user.Username),
Shortcut: &v1pb.Shortcut{ Shortcut: &v1pb.Shortcut{
Title: "Valid Filter " + string(rune(i)), Title: "Valid Filter " + string(rune(i)),
Filter: filter, Filter: filter,
...@@ -697,7 +716,7 @@ func TestShortcutFiltering(t *testing.T) { ...@@ -697,7 +716,7 @@ func TestShortcutFiltering(t *testing.T) {
for _, filter := range invalidFilters { for _, filter := range invalidFilters {
req := &v1pb.CreateShortcutRequest{ req := &v1pb.CreateShortcutRequest{
Parent: fmt.Sprintf("users/%d", user.ID), Parent: fmt.Sprintf("users/%s", user.Username),
Shortcut: &v1pb.Shortcut{ Shortcut: &v1pb.Shortcut{
Title: "Invalid Filter Test", Title: "Invalid Filter Test",
Filter: filter, Filter: filter,
...@@ -727,7 +746,7 @@ func TestShortcutCRUDComplete(t *testing.T) { ...@@ -727,7 +746,7 @@ func TestShortcutCRUDComplete(t *testing.T) {
// 1. Create multiple shortcuts // 1. Create multiple shortcuts
shortcut1Req := &v1pb.CreateShortcutRequest{ shortcut1Req := &v1pb.CreateShortcutRequest{
Parent: fmt.Sprintf("users/%d", user.ID), Parent: fmt.Sprintf("users/%s", user.Username),
Shortcut: &v1pb.Shortcut{ Shortcut: &v1pb.Shortcut{
Title: "Work Notes", Title: "Work Notes",
Filter: "tag in [\"work\"]", Filter: "tag in [\"work\"]",
...@@ -735,7 +754,7 @@ func TestShortcutCRUDComplete(t *testing.T) { ...@@ -735,7 +754,7 @@ func TestShortcutCRUDComplete(t *testing.T) {
} }
shortcut2Req := &v1pb.CreateShortcutRequest{ shortcut2Req := &v1pb.CreateShortcutRequest{
Parent: fmt.Sprintf("users/%d", user.ID), Parent: fmt.Sprintf("users/%s", user.Username),
Shortcut: &v1pb.Shortcut{ Shortcut: &v1pb.Shortcut{
Title: "Personal Notes", Title: "Personal Notes",
Filter: "tag in [\"personal\"]", Filter: "tag in [\"personal\"]",
...@@ -752,7 +771,7 @@ func TestShortcutCRUDComplete(t *testing.T) { ...@@ -752,7 +771,7 @@ func TestShortcutCRUDComplete(t *testing.T) {
// 2. List shortcuts and verify both exist // 2. List shortcuts and verify both exist
listReq := &v1pb.ListShortcutsRequest{ listReq := &v1pb.ListShortcutsRequest{
Parent: fmt.Sprintf("users/%d", user.ID), Parent: fmt.Sprintf("users/%s", user.Username),
} }
listResp, err := ts.Service.ListShortcuts(userCtx, listReq) listResp, err := ts.Service.ListShortcuts(userCtx, listReq)
......
...@@ -43,12 +43,14 @@ func TestListUserNotificationsIncludesMemoCommentPayload(t *testing.T) { ...@@ -43,12 +43,14 @@ func TestListUserNotificationsIncludesMemoCommentPayload(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
resp, err := ts.Service.ListUserNotifications(ownerCtx, &apiv1.ListUserNotificationsRequest{ resp, err := ts.Service.ListUserNotifications(ownerCtx, &apiv1.ListUserNotificationsRequest{
Parent: fmt.Sprintf("users/%d", owner.ID), Parent: fmt.Sprintf("users/%s", owner.Username),
}) })
require.NoError(t, err) require.NoError(t, err)
require.Len(t, resp.Notifications, 1) require.Len(t, resp.Notifications, 1)
notification := resp.Notifications[0] notification := resp.Notifications[0]
require.Contains(t, notification.Name, fmt.Sprintf("users/%s/notifications/", owner.Username))
require.Equal(t, fmt.Sprintf("users/%s", commenter.Username), notification.Sender)
require.Equal(t, apiv1.UserNotification_MEMO_COMMENT, notification.Type) require.Equal(t, apiv1.UserNotification_MEMO_COMMENT, notification.Type)
require.NotNil(t, notification.GetMemoComment()) require.NotNil(t, notification.GetMemoComment())
require.Equal(t, comment.Name, notification.GetMemoComment().Memo) require.Equal(t, comment.Name, notification.GetMemoComment().Memo)
...@@ -134,10 +136,26 @@ func TestListUserNotificationsOmitsPayloadWhenMemosDeleted(t *testing.T) { ...@@ -134,10 +136,26 @@ func TestListUserNotificationsOmitsPayloadWhenMemosDeleted(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
resp, err := ts.Service.ListUserNotifications(ownerCtx, &apiv1.ListUserNotificationsRequest{ resp, err := ts.Service.ListUserNotifications(ownerCtx, &apiv1.ListUserNotificationsRequest{
Parent: fmt.Sprintf("users/%d", owner.ID), Parent: fmt.Sprintf("users/%s", owner.Username),
}) })
require.NoError(t, err) require.NoError(t, err)
require.Len(t, resp.Notifications, 1) require.Len(t, resp.Notifications, 1)
require.Equal(t, apiv1.UserNotification_MEMO_COMMENT, resp.Notifications[0].Type) require.Equal(t, apiv1.UserNotification_MEMO_COMMENT, resp.Notifications[0].Type)
require.Nil(t, resp.Notifications[0].GetMemoComment()) require.Nil(t, resp.Notifications[0].GetMemoComment())
} }
func TestListUserNotificationsRejectsNumericParent(t *testing.T) {
ctx := context.Background()
ts := NewTestService(t)
defer ts.Cleanup()
owner, err := ts.CreateRegularUser(ctx, "notification-owner")
require.NoError(t, err)
ownerCtx := ts.CreateUserContext(ctx, owner.ID)
_, err = ts.Service.ListUserNotifications(ownerCtx, &apiv1.ListUserNotificationsRequest{
Parent: "users/1",
})
require.Error(t, err)
require.Contains(t, err.Error(), "invalid user name")
}
package test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
apiv1 "github.com/usememos/memos/proto/gen/api/v1"
)
func TestUserResourceName(t *testing.T) {
ctx := context.Background()
t.Run("GetUser returns username-based canonical name", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
user, err := ts.CreateRegularUser(ctx, "testuser")
require.NoError(t, err)
got, err := ts.Service.GetUser(ctx, &apiv1.GetUserRequest{
Name: "users/testuser",
})
require.NoError(t, err)
require.NotNil(t, got)
require.Equal(t, "users/testuser", got.Name)
require.Equal(t, user.Username, got.Username)
})
t.Run("CreateUser returns username-based canonical name", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
created, err := ts.Service.CreateUser(ctx, &apiv1.CreateUserRequest{
User: &apiv1.User{
Username: "newuser",
Email: "newuser@example.com",
Password: "password123",
},
})
require.NoError(t, err)
require.NotNil(t, created)
require.Equal(t, "users/newuser", created.Name)
})
t.Run("GetUser rejects numeric user resource names", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
_, err := ts.CreateRegularUser(ctx, "testuser")
require.NoError(t, err)
_, err = ts.Service.GetUser(ctx, &apiv1.GetUserRequest{
Name: "users/1",
})
require.Error(t, err)
require.Contains(t, err.Error(), "invalid user name")
})
}
...@@ -143,6 +143,7 @@ func TestCreateUserRegistration(t *testing.T) { ...@@ -143,6 +143,7 @@ func TestCreateUserRegistration(t *testing.T) {
}, },
}) })
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "users/newadmin", createdUser.Name)
require.NotNil(t, createdUser) require.NotNil(t, createdUser)
require.Equal(t, apiv1.User_ADMIN, createdUser.Role) require.Equal(t, apiv1.User_ADMIN, createdUser.Role)
}) })
...@@ -168,6 +169,7 @@ func TestCreateUserRegistration(t *testing.T) { ...@@ -168,6 +169,7 @@ func TestCreateUserRegistration(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, createdUser) require.NotNil(t, createdUser)
require.Equal(t, "users/wannabeadmin", createdUser.Name)
require.Equal(t, apiv1.User_USER, createdUser.Role, "Unauthenticated users can only create USER role") require.Equal(t, apiv1.User_USER, createdUser.Role, "Unauthenticated users can only create USER role")
}) })
} }
...@@ -20,7 +20,7 @@ func TestGetUserStats_TagCount(t *testing.T) { ...@@ -20,7 +20,7 @@ func TestGetUserStats_TagCount(t *testing.T) {
defer ts.Cleanup() defer ts.Cleanup()
// Create a test host user // Create a test host user
user, err := ts.CreateHostUser(ctx, "test_user") user, err := ts.CreateHostUser(ctx, "test-user")
require.NoError(t, err) require.NoError(t, err)
// Create user context for authentication // Create user context for authentication
...@@ -40,12 +40,13 @@ func TestGetUserStats_TagCount(t *testing.T) { ...@@ -40,12 +40,13 @@ func TestGetUserStats_TagCount(t *testing.T) {
require.NotNil(t, memo) require.NotNil(t, memo)
// Test GetUserStats // Test GetUserStats
userName := fmt.Sprintf("users/%d", user.ID) userName := fmt.Sprintf("users/%s", user.Username)
response, err := ts.Service.GetUserStats(userCtx, &v1pb.GetUserStatsRequest{ response, err := ts.Service.GetUserStats(userCtx, &v1pb.GetUserStatsRequest{
Name: userName, Name: userName,
}) })
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, response) require.NotNil(t, response)
require.Equal(t, fmt.Sprintf("users/%s/stats", user.Username), response.Name)
// Check that the tag count is exactly 1, not 2 // Check that the tag count is exactly 1, not 2
require.Contains(t, response.TagCount, "test") require.Contains(t, response.TagCount, "test")
...@@ -102,4 +103,10 @@ func TestGetUserStats_TagCount(t *testing.T) { ...@@ -102,4 +103,10 @@ func TestGetUserStats_TagCount(t *testing.T) {
// The original test tag should still be 2 // The original test tag should still be 2
require.Contains(t, response3.TagCount, "test") require.Contains(t, response3.TagCount, "test")
require.Equal(t, int32(2), response3.TagCount["test"], "Original tag count should remain 2") require.Equal(t, int32(2), response3.TagCount["test"], "Original tag count should remain 2")
_, err = ts.Service.GetUserStats(userCtx, &v1pb.GetUserStatsRequest{
Name: "users/1",
})
require.Error(t, err)
require.Contains(t, err.Error(), "invalid user name")
} }
package v1
import (
"context"
"strconv"
"strings"
"github.com/pkg/errors"
"github.com/usememos/memos/internal/base"
"github.com/usememos/memos/store"
)
// BuildUserName returns the canonical public resource name for a user.
func BuildUserName(username string) string {
return UserNamePrefix + username
}
// ExtractUsernameFromName extracts the username token from a user resource name.
func ExtractUsernameFromName(name string) (string, error) {
tokens, err := GetNameParentTokens(name, UserNamePrefix)
if err != nil {
return "", err
}
username := tokens[0]
if username == "" {
return "", errors.Errorf("invalid user name %q", name)
}
if _, err := strconv.ParseInt(username, 10, 32); err == nil {
return "", errors.Errorf("invalid username %q", username)
}
if username != strings.ToLower(username) || !base.UIDMatcher.MatchString(username) {
return "", errors.Errorf("invalid username %q", username)
}
return username, nil
}
// ResolveUserByName resolves a username-based user resource name to a store user.
func ResolveUserByName(ctx context.Context, stores *store.Store, name string) (*store.User, error) {
username, err := ExtractUsernameFromName(name)
if err != nil {
return nil, err
}
user, err := stores.GetUser(ctx, &store.FindUser{Username: &username})
if err != nil {
return nil, errors.Wrap(err, "resolve user by name: GetUser failed")
}
return user, nil
}
...@@ -70,31 +70,9 @@ func (s *APIV1Service) ListUsers(ctx context.Context, request *v1pb.ListUsersReq ...@@ -70,31 +70,9 @@ func (s *APIV1Service) ListUsers(ctx context.Context, request *v1pb.ListUsersReq
} }
func (s *APIV1Service) GetUser(ctx context.Context, request *v1pb.GetUserRequest) (*v1pb.User, error) { func (s *APIV1Service) GetUser(ctx context.Context, request *v1pb.GetUserRequest) (*v1pb.User, error) {
// Extract identifier from "users/{id_or_username}" user, err := ResolveUserByName(ctx, s.Store, request.Name)
identifier := extractUserIdentifierFromName(request.Name)
if identifier == "" {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %s", request.Name)
}
var user *store.User
var err error
// Try to parse as numeric ID first
if userID, parseErr := strconv.ParseInt(identifier, 10, 32); parseErr == nil {
// It's a numeric ID
userID32 := int32(userID)
user, err = s.Store.GetUser(ctx, &store.FindUser{
ID: &userID32,
})
} else {
// It's a username
user, err = s.Store.GetUser(ctx, &store.FindUser{
Username: &identifier,
})
}
if err != nil { if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %s", request.Name)
} }
if user == nil { if user == nil {
return nil, status.Errorf(codes.NotFound, "user not found") return nil, status.Errorf(codes.NotFound, "user not found")
...@@ -183,10 +161,17 @@ func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserR ...@@ -183,10 +161,17 @@ func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserR
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 { if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty") return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
} }
userID, err := ExtractUserIDFromName(request.User.Name) user, err := ResolveUserByName(ctx, s.Store, request.User.Name)
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
} }
if user == nil {
if request.AllowMissing {
return nil, status.Errorf(codes.NotFound, "user not found")
}
return nil, status.Errorf(codes.NotFound, "user not found")
}
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx) currentUser, err := s.fetchCurrentUser(ctx)
if err != nil { if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err) return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
...@@ -200,19 +185,6 @@ func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserR ...@@ -200,19 +185,6 @@ func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserR
return nil, status.Errorf(codes.PermissionDenied, "permission denied") return nil, status.Errorf(codes.PermissionDenied, "permission denied")
} }
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &userID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
if user == nil {
// Handle allow_missing field
if request.AllowMissing {
// Could create user if missing, but for now return not found
return nil, status.Errorf(codes.NotFound, "user not found")
}
return nil, status.Errorf(codes.NotFound, "user not found")
}
currentTs := time.Now().Unix() currentTs := time.Now().Unix()
update := &store.UpdateUser{ update := &store.UpdateUser{
ID: user.ID, ID: user.ID,
...@@ -292,10 +264,14 @@ func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserR ...@@ -292,10 +264,14 @@ func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserR
} }
func (s *APIV1Service) DeleteUser(ctx context.Context, request *v1pb.DeleteUserRequest) (*emptypb.Empty, error) { func (s *APIV1Service) DeleteUser(ctx context.Context, request *v1pb.DeleteUserRequest) (*emptypb.Empty, error) {
userID, err := ExtractUserIDFromName(request.Name) user, err := ResolveUserByName(ctx, s.Store, request.Name)
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
} }
if user == nil {
return nil, status.Errorf(codes.NotFound, "user not found")
}
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx) currentUser, err := s.fetchCurrentUser(ctx)
if err != nil { if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err) return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
...@@ -307,14 +283,6 @@ func (s *APIV1Service) DeleteUser(ctx context.Context, request *v1pb.DeleteUserR ...@@ -307,14 +283,6 @@ func (s *APIV1Service) DeleteUser(ctx context.Context, request *v1pb.DeleteUserR
return nil, status.Errorf(codes.PermissionDenied, "permission denied") return nil, status.Errorf(codes.PermissionDenied, "permission denied")
} }
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &userID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.NotFound, "user not found")
}
if err := s.Store.DeleteUser(ctx, &store.DeleteUser{ if err := s.Store.DeleteUser(ctx, &store.DeleteUser{
ID: user.ID, ID: user.ID,
}); err != nil { }); err != nil {
...@@ -332,12 +300,69 @@ func getDefaultUserGeneralSetting() *v1pb.UserSetting_GeneralSetting { ...@@ -332,12 +300,69 @@ func getDefaultUserGeneralSetting() *v1pb.UserSetting_GeneralSetting {
} }
} }
func (s *APIV1Service) resolveUserFromName(ctx context.Context, name string) (*store.User, error) {
user, err := ResolveUserByName(ctx, s.Store, name)
if err != nil {
return nil, err
}
if user == nil {
return nil, errors.Errorf("user not found: %s", name)
}
return user, nil
}
func (s *APIV1Service) resolveUserAndSettingKeyFromName(ctx context.Context, name string) (*store.User, string, error) {
parts := strings.Split(name, "/")
if len(parts) != 4 || parts[0] != "users" || parts[2] != "settings" {
return nil, "", errors.Errorf("invalid resource name format: %s", name)
}
user, err := s.resolveUserFromName(ctx, BuildUserName(parts[1]))
if err != nil {
return nil, "", err
}
return user, parts[3], nil
}
func (s *APIV1Service) resolveUserAndWebhookIDFromName(ctx context.Context, name string) (*store.User, string, error) {
parts := strings.Split(name, "/")
if len(parts) != 4 || parts[0] != "users" || parts[2] != "webhooks" {
return nil, "", errors.New("invalid webhook name format")
}
user, err := s.resolveUserFromName(ctx, BuildUserName(parts[1]))
if err != nil {
return nil, "", err
}
return user, parts[3], nil
}
func (s *APIV1Service) resolveUserAndNotificationIDFromName(ctx context.Context, name string) (*store.User, int32, error) {
parts := strings.Split(name, "/")
if len(parts) != 4 || parts[0] != "users" || parts[2] != "notifications" {
return nil, 0, errors.Errorf("invalid notification name: %s", name)
}
user, err := s.resolveUserFromName(ctx, BuildUserName(parts[1]))
if err != nil {
return nil, 0, err
}
id, err := strconv.Atoi(parts[3])
if err != nil {
return nil, 0, errors.Errorf("invalid notification id: %s", parts[3])
}
return user, int32(id), nil
}
func (s *APIV1Service) GetUserSetting(ctx context.Context, request *v1pb.GetUserSettingRequest) (*v1pb.UserSetting, error) { func (s *APIV1Service) GetUserSetting(ctx context.Context, request *v1pb.GetUserSettingRequest) (*v1pb.UserSetting, error) {
// Parse resource name: users/{user}/settings/{setting} // Parse resource name: users/{user}/settings/{setting}
userID, settingKey, err := ExtractUserIDAndSettingKeyFromName(request.Name) user, settingKey, err := s.resolveUserAndSettingKeyFromName(ctx, request.Name)
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid resource name: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid resource name: %v", err)
} }
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx) currentUser, err := s.fetchCurrentUser(ctx)
if err != nil { if err != nil {
...@@ -366,15 +391,16 @@ func (s *APIV1Service) GetUserSetting(ctx context.Context, request *v1pb.GetUser ...@@ -366,15 +391,16 @@ func (s *APIV1Service) GetUserSetting(ctx context.Context, request *v1pb.GetUser
return nil, status.Errorf(codes.Internal, "failed to get user setting: %v", err) return nil, status.Errorf(codes.Internal, "failed to get user setting: %v", err)
} }
return convertUserSettingFromStore(userSetting, userID, storeKey), nil return convertUserSettingFromStore(userSetting, user, storeKey), nil
} }
func (s *APIV1Service) UpdateUserSetting(ctx context.Context, request *v1pb.UpdateUserSettingRequest) (*v1pb.UserSetting, error) { func (s *APIV1Service) UpdateUserSetting(ctx context.Context, request *v1pb.UpdateUserSettingRequest) (*v1pb.UserSetting, error) {
// Parse resource name: users/{user}/settings/{setting} // Parse resource name: users/{user}/settings/{setting}
userID, settingKey, err := ExtractUserIDAndSettingKeyFromName(request.Setting.Name) user, settingKey, err := s.resolveUserAndSettingKeyFromName(ctx, request.Setting.Name)
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid resource name: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid resource name: %v", err)
} }
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx) currentUser, err := s.fetchCurrentUser(ctx)
if err != nil { if err != nil {
...@@ -461,10 +487,11 @@ func (s *APIV1Service) UpdateUserSetting(ctx context.Context, request *v1pb.Upda ...@@ -461,10 +487,11 @@ func (s *APIV1Service) UpdateUserSetting(ctx context.Context, request *v1pb.Upda
} }
func (s *APIV1Service) ListUserSettings(ctx context.Context, request *v1pb.ListUserSettingsRequest) (*v1pb.ListUserSettingsResponse, error) { func (s *APIV1Service) ListUserSettings(ctx context.Context, request *v1pb.ListUserSettingsRequest) (*v1pb.ListUserSettingsResponse, error) {
userID, err := ExtractUserIDFromName(request.Parent) user, err := s.resolveUserFromName(ctx, request.Parent)
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid parent name: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid parent name: %v", err)
} }
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx) currentUser, err := s.fetchCurrentUser(ctx)
if err != nil { if err != nil {
...@@ -488,7 +515,7 @@ func (s *APIV1Service) ListUserSettings(ctx context.Context, request *v1pb.ListU ...@@ -488,7 +515,7 @@ func (s *APIV1Service) ListUserSettings(ctx context.Context, request *v1pb.ListU
settings := make([]*v1pb.UserSetting, 0, len(userSettings)) settings := make([]*v1pb.UserSetting, 0, len(userSettings))
for _, storeSetting := range userSettings { for _, storeSetting := range userSettings {
apiSetting := convertUserSettingFromStore(storeSetting, userID, storeSetting.Key) apiSetting := convertUserSettingFromStore(storeSetting, user, storeSetting.Key)
if apiSetting != nil { if apiSetting != nil {
settings = append(settings, apiSetting) settings = append(settings, apiSetting)
} }
...@@ -502,7 +529,7 @@ func (s *APIV1Service) ListUserSettings(ctx context.Context, request *v1pb.ListU ...@@ -502,7 +529,7 @@ func (s *APIV1Service) ListUserSettings(ctx context.Context, request *v1pb.ListU
} }
if !hasGeneral { if !hasGeneral {
defaultGeneral := &v1pb.UserSetting{ defaultGeneral := &v1pb.UserSetting{
Name: fmt.Sprintf("users/%d/settings/%s", userID, convertSettingKeyFromStore(storepb.UserSetting_GENERAL)), Name: fmt.Sprintf("%s/settings/%s", BuildUserName(user.Username), convertSettingKeyFromStore(storepb.UserSetting_GENERAL)),
Value: &v1pb.UserSetting_GeneralSetting_{ Value: &v1pb.UserSetting_GeneralSetting_{
GeneralSetting: getDefaultUserGeneralSetting(), GeneralSetting: getDefaultUserGeneralSetting(),
}, },
...@@ -533,10 +560,11 @@ func (s *APIV1Service) ListUserSettings(ctx context.Context, request *v1pb.ListU ...@@ -533,10 +560,11 @@ func (s *APIV1Service) ListUserSettings(ctx context.Context, request *v1pb.ListU
// Authentication: Required (session cookie or access token) // Authentication: Required (session cookie or access token)
// Authorization: User can only list their own tokens. // Authorization: User can only list their own tokens.
func (s *APIV1Service) ListPersonalAccessTokens(ctx context.Context, request *v1pb.ListPersonalAccessTokensRequest) (*v1pb.ListPersonalAccessTokensResponse, error) { func (s *APIV1Service) ListPersonalAccessTokens(ctx context.Context, request *v1pb.ListPersonalAccessTokensRequest) (*v1pb.ListPersonalAccessTokensResponse, error) {
userID, err := ExtractUserIDFromName(request.Parent) user, err := s.resolveUserFromName(ctx, request.Parent)
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
} }
userID := user.ID
// Verify permission // Verify permission
claims := auth.GetUserClaims(ctx) claims := auth.GetUserClaims(ctx)
...@@ -555,7 +583,7 @@ func (s *APIV1Service) ListPersonalAccessTokens(ctx context.Context, request *v1 ...@@ -555,7 +583,7 @@ func (s *APIV1Service) ListPersonalAccessTokens(ctx context.Context, request *v1
personalAccessTokens := make([]*v1pb.PersonalAccessToken, len(tokens)) personalAccessTokens := make([]*v1pb.PersonalAccessToken, len(tokens))
for i, token := range tokens { for i, token := range tokens {
personalAccessTokens[i] = &v1pb.PersonalAccessToken{ personalAccessTokens[i] = &v1pb.PersonalAccessToken{
Name: fmt.Sprintf("%s/personalAccessTokens/%s", request.Parent, token.TokenId), Name: fmt.Sprintf("%s/personalAccessTokens/%s", BuildUserName(user.Username), token.TokenId),
Description: token.Description, Description: token.Description,
ExpiresAt: token.ExpiresAt, ExpiresAt: token.ExpiresAt,
CreatedAt: token.CreatedAt, CreatedAt: token.CreatedAt,
...@@ -587,10 +615,11 @@ func (s *APIV1Service) ListPersonalAccessTokens(ctx context.Context, request *v1 ...@@ -587,10 +615,11 @@ func (s *APIV1Service) ListPersonalAccessTokens(ctx context.Context, request *v1
// Authentication: Required (session cookie or access token) // Authentication: Required (session cookie or access token)
// Authorization: User can only create tokens for themselves. // Authorization: User can only create tokens for themselves.
func (s *APIV1Service) CreatePersonalAccessToken(ctx context.Context, request *v1pb.CreatePersonalAccessTokenRequest) (*v1pb.CreatePersonalAccessTokenResponse, error) { func (s *APIV1Service) CreatePersonalAccessToken(ctx context.Context, request *v1pb.CreatePersonalAccessTokenRequest) (*v1pb.CreatePersonalAccessTokenResponse, error) {
userID, err := ExtractUserIDFromName(request.Parent) user, err := s.resolveUserFromName(ctx, request.Parent)
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
} }
userID := user.ID
// Verify permission // Verify permission
claims := auth.GetUserClaims(ctx) claims := auth.GetUserClaims(ctx)
...@@ -625,7 +654,7 @@ func (s *APIV1Service) CreatePersonalAccessToken(ctx context.Context, request *v ...@@ -625,7 +654,7 @@ func (s *APIV1Service) CreatePersonalAccessToken(ctx context.Context, request *v
return &v1pb.CreatePersonalAccessTokenResponse{ return &v1pb.CreatePersonalAccessTokenResponse{
PersonalAccessToken: &v1pb.PersonalAccessToken{ PersonalAccessToken: &v1pb.PersonalAccessToken{
Name: fmt.Sprintf("%s/personalAccessTokens/%s", request.Parent, tokenID), Name: fmt.Sprintf("%s/personalAccessTokens/%s", BuildUserName(user.Username), tokenID),
Description: request.Description, Description: request.Description,
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
CreatedAt: patRecord.CreatedAt, CreatedAt: patRecord.CreatedAt,
...@@ -648,16 +677,16 @@ func (s *APIV1Service) CreatePersonalAccessToken(ctx context.Context, request *v ...@@ -648,16 +677,16 @@ func (s *APIV1Service) CreatePersonalAccessToken(ctx context.Context, request *v
// Authentication: Required (session cookie or access token) // Authentication: Required (session cookie or access token)
// Authorization: User can only delete their own tokens. // Authorization: User can only delete their own tokens.
func (s *APIV1Service) DeletePersonalAccessToken(ctx context.Context, request *v1pb.DeletePersonalAccessTokenRequest) (*emptypb.Empty, error) { func (s *APIV1Service) DeletePersonalAccessToken(ctx context.Context, request *v1pb.DeletePersonalAccessTokenRequest) (*emptypb.Empty, error) {
// Parse name: users/{user_id}/personalAccessTokens/{token_id}
parts := strings.Split(request.Name, "/") parts := strings.Split(request.Name, "/")
if len(parts) != 4 || parts[0] != "users" || parts[2] != "personalAccessTokens" { if len(parts) != 4 || parts[0] != "users" || parts[2] != "personalAccessTokens" {
return nil, status.Errorf(codes.InvalidArgument, "invalid personal access token name") return nil, status.Errorf(codes.InvalidArgument, "invalid personal access token name")
} }
userID, err := util.ConvertStringToInt32(parts[1]) user, err := s.resolveUserFromName(ctx, BuildUserName(parts[1]))
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user ID: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
} }
userID := user.ID
tokenID := parts[3] tokenID := parts[3]
// Verify permission // Verify permission
...@@ -677,10 +706,11 @@ func (s *APIV1Service) DeletePersonalAccessToken(ctx context.Context, request *v ...@@ -677,10 +706,11 @@ func (s *APIV1Service) DeletePersonalAccessToken(ctx context.Context, request *v
} }
func (s *APIV1Service) ListUserWebhooks(ctx context.Context, request *v1pb.ListUserWebhooksRequest) (*v1pb.ListUserWebhooksResponse, error) { func (s *APIV1Service) ListUserWebhooks(ctx context.Context, request *v1pb.ListUserWebhooksRequest) (*v1pb.ListUserWebhooksResponse, error) {
userID, err := ExtractUserIDFromName(request.Parent) user, err := s.resolveUserFromName(ctx, request.Parent)
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid parent: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid parent: %v", err)
} }
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx) currentUser, err := s.fetchCurrentUser(ctx)
if err != nil { if err != nil {
...@@ -700,7 +730,7 @@ func (s *APIV1Service) ListUserWebhooks(ctx context.Context, request *v1pb.ListU ...@@ -700,7 +730,7 @@ func (s *APIV1Service) ListUserWebhooks(ctx context.Context, request *v1pb.ListU
userWebhooks := make([]*v1pb.UserWebhook, 0, len(webhooks)) userWebhooks := make([]*v1pb.UserWebhook, 0, len(webhooks))
for _, webhook := range webhooks { for _, webhook := range webhooks {
userWebhooks = append(userWebhooks, convertUserWebhookFromUserSetting(webhook, userID)) userWebhooks = append(userWebhooks, convertUserWebhookFromUserSetting(webhook, user))
} }
return &v1pb.ListUserWebhooksResponse{ return &v1pb.ListUserWebhooksResponse{
...@@ -709,10 +739,11 @@ func (s *APIV1Service) ListUserWebhooks(ctx context.Context, request *v1pb.ListU ...@@ -709,10 +739,11 @@ func (s *APIV1Service) ListUserWebhooks(ctx context.Context, request *v1pb.ListU
} }
func (s *APIV1Service) CreateUserWebhook(ctx context.Context, request *v1pb.CreateUserWebhookRequest) (*v1pb.UserWebhook, error) { func (s *APIV1Service) CreateUserWebhook(ctx context.Context, request *v1pb.CreateUserWebhookRequest) (*v1pb.UserWebhook, error) {
userID, err := ExtractUserIDFromName(request.Parent) user, err := s.resolveUserFromName(ctx, request.Parent)
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid parent: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid parent: %v", err)
} }
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx) currentUser, err := s.fetchCurrentUser(ctx)
if err != nil { if err != nil {
...@@ -744,7 +775,7 @@ func (s *APIV1Service) CreateUserWebhook(ctx context.Context, request *v1pb.Crea ...@@ -744,7 +775,7 @@ func (s *APIV1Service) CreateUserWebhook(ctx context.Context, request *v1pb.Crea
return nil, status.Errorf(codes.Internal, "failed to create webhook: %v", err) return nil, status.Errorf(codes.Internal, "failed to create webhook: %v", err)
} }
return convertUserWebhookFromUserSetting(webhook, userID), nil return convertUserWebhookFromUserSetting(webhook, user), nil
} }
func (s *APIV1Service) UpdateUserWebhook(ctx context.Context, request *v1pb.UpdateUserWebhookRequest) (*v1pb.UserWebhook, error) { func (s *APIV1Service) UpdateUserWebhook(ctx context.Context, request *v1pb.UpdateUserWebhookRequest) (*v1pb.UserWebhook, error) {
...@@ -752,10 +783,11 @@ func (s *APIV1Service) UpdateUserWebhook(ctx context.Context, request *v1pb.Upda ...@@ -752,10 +783,11 @@ func (s *APIV1Service) UpdateUserWebhook(ctx context.Context, request *v1pb.Upda
return nil, status.Errorf(codes.InvalidArgument, "webhook is required") return nil, status.Errorf(codes.InvalidArgument, "webhook is required")
} }
webhookID, userID, err := parseUserWebhookName(request.Webhook.Name) user, webhookID, err := s.resolveUserAndWebhookIDFromName(ctx, request.Webhook.Name)
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid webhook name: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid webhook name: %v", err)
} }
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx) currentUser, err := s.fetchCurrentUser(ctx)
if err != nil { if err != nil {
...@@ -828,14 +860,15 @@ func (s *APIV1Service) UpdateUserWebhook(ctx context.Context, request *v1pb.Upda ...@@ -828,14 +860,15 @@ func (s *APIV1Service) UpdateUserWebhook(ctx context.Context, request *v1pb.Upda
return nil, status.Errorf(codes.Internal, "failed to update webhook: %v", err) return nil, status.Errorf(codes.Internal, "failed to update webhook: %v", err)
} }
return convertUserWebhookFromUserSetting(updatedWebhook, userID), nil return convertUserWebhookFromUserSetting(updatedWebhook, user), nil
} }
func (s *APIV1Service) DeleteUserWebhook(ctx context.Context, request *v1pb.DeleteUserWebhookRequest) (*emptypb.Empty, error) { func (s *APIV1Service) DeleteUserWebhook(ctx context.Context, request *v1pb.DeleteUserWebhookRequest) (*emptypb.Empty, error) {
webhookID, userID, err := parseUserWebhookName(request.Name) user, webhookID, err := s.resolveUserAndWebhookIDFromName(ctx, request.Name)
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid webhook name: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid webhook name: %v", err)
} }
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx) currentUser, err := s.fetchCurrentUser(ctx)
if err != nil { if err != nil {
...@@ -884,26 +917,10 @@ func generateUserWebhookID() string { ...@@ -884,26 +917,10 @@ func generateUserWebhookID() string {
return hex.EncodeToString(b) return hex.EncodeToString(b)
} }
// parseUserWebhookName parses a webhook name and returns the webhook ID and user ID.
// Format: users/{user}/webhooks/{webhook}.
func parseUserWebhookName(name string) (string, int32, error) {
parts := strings.Split(name, "/")
if len(parts) != 4 || parts[0] != "users" || parts[2] != "webhooks" {
return "", 0, errors.New("invalid webhook name format")
}
userID, err := strconv.ParseInt(parts[1], 10, 32)
if err != nil {
return "", 0, errors.New("invalid user ID in webhook name")
}
return parts[3], int32(userID), nil
}
// convertUserWebhookFromUserSetting converts a storepb webhook to a v1pb UserWebhook. // convertUserWebhookFromUserSetting converts a storepb webhook to a v1pb UserWebhook.
func convertUserWebhookFromUserSetting(webhook *storepb.WebhooksUserSetting_Webhook, userID int32) *v1pb.UserWebhook { func convertUserWebhookFromUserSetting(webhook *storepb.WebhooksUserSetting_Webhook, user *store.User) *v1pb.UserWebhook {
return &v1pb.UserWebhook{ return &v1pb.UserWebhook{
Name: fmt.Sprintf("users/%d/webhooks/%s", userID, webhook.Id), Name: fmt.Sprintf("%s/webhooks/%s", BuildUserName(user.Username), webhook.Id),
Url: webhook.Url, Url: webhook.Url,
DisplayName: webhook.Title, DisplayName: webhook.Title,
// Note: create_time and update_time are not available in the user setting webhook structure // Note: create_time and update_time are not available in the user setting webhook structure
...@@ -913,7 +930,7 @@ func convertUserWebhookFromUserSetting(webhook *storepb.WebhooksUserSetting_Webh ...@@ -913,7 +930,7 @@ func convertUserWebhookFromUserSetting(webhook *storepb.WebhooksUserSetting_Webh
func convertUserFromStore(user *store.User) *v1pb.User { func convertUserFromStore(user *store.User) *v1pb.User {
userpb := &v1pb.User{ userpb := &v1pb.User{
Name: fmt.Sprintf("%s%d", UserNamePrefix, user.ID), Name: BuildUserName(user.Username),
State: convertStateFromStore(user.RowStatus), State: convertStateFromStore(user.RowStatus),
CreateTime: timestamppb.New(time.Unix(user.CreatedTs, 0)), CreateTime: timestamppb.New(time.Unix(user.CreatedTs, 0)),
UpdateTime: timestamppb.New(time.Unix(user.UpdatedTs, 0)), UpdateTime: timestamppb.New(time.Unix(user.UpdatedTs, 0)),
...@@ -970,26 +987,6 @@ func extractImageInfo(dataURI string) (string, string, error) { ...@@ -970,26 +987,6 @@ func extractImageInfo(dataURI string) (string, string, error) {
return imageType, base64Data, nil return imageType, base64Data, nil
} }
// Helper functions for user settings
// ExtractUserIDAndSettingKeyFromName extracts user ID and setting key from resource name.
// e.g., "users/123/settings/general" -> 123, "general".
func ExtractUserIDAndSettingKeyFromName(name string) (int32, string, error) {
// Expected format: users/{user}/settings/{setting}
parts := strings.Split(name, "/")
if len(parts) != 4 || parts[0] != "users" || parts[2] != "settings" {
return 0, "", errors.Errorf("invalid resource name format: %s", name)
}
userID, err := util.ConvertStringToInt32(parts[1])
if err != nil {
return 0, "", errors.Errorf("invalid user ID: %s", parts[1])
}
settingKey := parts[3]
return userID, settingKey, nil
}
// convertSettingKeyToStore converts API setting key to store enum. // convertSettingKeyToStore converts API setting key to store enum.
func convertSettingKeyToStore(key string) (storepb.UserSetting_Key, error) { func convertSettingKeyToStore(key string) (storepb.UserSetting_Key, error) {
switch key { switch key {
...@@ -1017,12 +1014,12 @@ func convertSettingKeyFromStore(key storepb.UserSetting_Key) string { ...@@ -1017,12 +1014,12 @@ func convertSettingKeyFromStore(key storepb.UserSetting_Key) string {
} }
// convertUserSettingFromStore converts store UserSetting to API UserSetting. // convertUserSettingFromStore converts store UserSetting to API UserSetting.
func convertUserSettingFromStore(storeSetting *storepb.UserSetting, userID int32, key storepb.UserSetting_Key) *v1pb.UserSetting { func convertUserSettingFromStore(storeSetting *storepb.UserSetting, user *store.User, key storepb.UserSetting_Key) *v1pb.UserSetting {
if storeSetting == nil { if storeSetting == nil {
// Return default setting if none exists // Return default setting if none exists
settingKey := convertSettingKeyFromStore(key) settingKey := convertSettingKeyFromStore(key)
setting := &v1pb.UserSetting{ setting := &v1pb.UserSetting{
Name: fmt.Sprintf("users/%d/settings/%s", userID, settingKey), Name: fmt.Sprintf("%s/settings/%s", BuildUserName(user.Username), settingKey),
} }
switch key { switch key {
...@@ -1043,7 +1040,7 @@ func convertUserSettingFromStore(storeSetting *storepb.UserSetting, userID int32 ...@@ -1043,7 +1040,7 @@ func convertUserSettingFromStore(storeSetting *storepb.UserSetting, userID int32
settingKey := convertSettingKeyFromStore(storeSetting.Key) settingKey := convertSettingKeyFromStore(storeSetting.Key)
setting := &v1pb.UserSetting{ setting := &v1pb.UserSetting{
Name: fmt.Sprintf("users/%d/settings/%s", userID, settingKey), Name: fmt.Sprintf("%s/settings/%s", BuildUserName(user.Username), settingKey),
} }
switch storeSetting.Key { switch storeSetting.Key {
...@@ -1063,14 +1060,17 @@ func convertUserSettingFromStore(storeSetting *storepb.UserSetting, userID int32 ...@@ -1063,14 +1060,17 @@ func convertUserSettingFromStore(storeSetting *storepb.UserSetting, userID int32
} }
case storepb.UserSetting_WEBHOOKS: case storepb.UserSetting_WEBHOOKS:
webhooks := storeSetting.GetWebhooks() webhooks := storeSetting.GetWebhooks()
apiWebhooks := make([]*v1pb.UserWebhook, 0, len(webhooks.Webhooks)) apiWebhooks := make([]*v1pb.UserWebhook, 0)
for _, webhook := range webhooks.Webhooks { if webhooks != nil {
apiWebhook := &v1pb.UserWebhook{ apiWebhooks = make([]*v1pb.UserWebhook, 0, len(webhooks.Webhooks))
Name: fmt.Sprintf("users/%d/webhooks/%s", userID, webhook.Id), for _, webhook := range webhooks.Webhooks {
Url: webhook.Url, apiWebhook := &v1pb.UserWebhook{
DisplayName: webhook.Title, Name: fmt.Sprintf("%s/webhooks/%s", BuildUserName(user.Username), webhook.Id),
Url: webhook.Url,
DisplayName: webhook.Title,
}
apiWebhooks = append(apiWebhooks, apiWebhook)
} }
apiWebhooks = append(apiWebhooks, apiWebhook)
} }
setting.Value = &v1pb.UserSetting_WebhooksSetting_{ setting.Value = &v1pb.UserSetting_WebhooksSetting_{
WebhooksSetting: &v1pb.UserSetting_WebhooksSetting{ WebhooksSetting: &v1pb.UserSetting_WebhooksSetting{
...@@ -1240,10 +1240,11 @@ func extractUsernameFromComparison(left, right ast.Expr) (string, bool) { ...@@ -1240,10 +1240,11 @@ func extractUsernameFromComparison(left, right ast.Expr) (string, bool) {
// Notifications are backed by the inbox storage layer and represent activities // Notifications are backed by the inbox storage layer and represent activities
// that require user attention (e.g., memo comments). // that require user attention (e.g., memo comments).
func (s *APIV1Service) ListUserNotifications(ctx context.Context, request *v1pb.ListUserNotificationsRequest) (*v1pb.ListUserNotificationsResponse, error) { func (s *APIV1Service) ListUserNotifications(ctx context.Context, request *v1pb.ListUserNotificationsRequest) (*v1pb.ListUserNotificationsResponse, error) {
userID, err := ExtractUserIDFromName(request.Parent) user, err := s.resolveUserFromName(ctx, request.Parent)
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
} }
userID := user.ID
// Verify the requesting user has permission to view these notifications // Verify the requesting user has permission to view these notifications
currentUser, err := s.fetchCurrentUser(ctx) currentUser, err := s.fetchCurrentUser(ctx)
...@@ -1268,10 +1269,19 @@ func (s *APIV1Service) ListUserNotifications(ctx context.Context, request *v1pb. ...@@ -1268,10 +1269,19 @@ func (s *APIV1Service) ListUserNotifications(ctx context.Context, request *v1pb.
return nil, status.Errorf(codes.Internal, "failed to list inboxes: %v", err) return nil, status.Errorf(codes.Internal, "failed to list inboxes: %v", err)
} }
// Convert storage layer inboxes to API notifications // Convert storage layer inboxes to API notifications.
userIDs := make([]int32, 0, len(inboxes)*2)
for _, inbox := range inboxes {
userIDs = append(userIDs, inbox.ReceiverID, inbox.SenderID)
}
usersByID, err := s.listUsersByID(ctx, userIDs)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list notification users: %v", err)
}
notifications := []*v1pb.UserNotification{} notifications := []*v1pb.UserNotification{}
for _, inbox := range inboxes { for _, inbox := range inboxes {
notification, err := s.convertInboxToUserNotification(ctx, inbox) notification, err := s.convertInboxToUserNotificationWithUsers(ctx, inbox, usersByID)
if err != nil { if err != nil {
return nil, status.Errorf(codes.Internal, "failed to convert inbox: %v", err) return nil, status.Errorf(codes.Internal, "failed to convert inbox: %v", err)
} }
...@@ -1290,7 +1300,7 @@ func (s *APIV1Service) UpdateUserNotification(ctx context.Context, request *v1pb ...@@ -1290,7 +1300,7 @@ func (s *APIV1Service) UpdateUserNotification(ctx context.Context, request *v1pb
return nil, status.Errorf(codes.InvalidArgument, "notification is required") return nil, status.Errorf(codes.InvalidArgument, "notification is required")
} }
notificationID, err := ExtractNotificationIDFromName(request.Notification.Name) user, notificationID, err := s.resolveUserAndNotificationIDFromName(ctx, request.Notification.Name)
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid notification name: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid notification name: %v", err)
} }
...@@ -1303,6 +1313,9 @@ func (s *APIV1Service) UpdateUserNotification(ctx context.Context, request *v1pb ...@@ -1303,6 +1313,9 @@ func (s *APIV1Service) UpdateUserNotification(ctx context.Context, request *v1pb
if currentUser == nil { if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated") return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
} }
if currentUser.ID != user.ID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
// Verify ownership before updating // Verify ownership before updating
inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{ inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{
ID: &notificationID, ID: &notificationID,
...@@ -1358,7 +1371,7 @@ func (s *APIV1Service) UpdateUserNotification(ctx context.Context, request *v1pb ...@@ -1358,7 +1371,7 @@ func (s *APIV1Service) UpdateUserNotification(ctx context.Context, request *v1pb
// DeleteUserNotification permanently deletes a notification. // DeleteUserNotification permanently deletes a notification.
// Only the notification owner can delete their notifications. // Only the notification owner can delete their notifications.
func (s *APIV1Service) DeleteUserNotification(ctx context.Context, request *v1pb.DeleteUserNotificationRequest) (*emptypb.Empty, error) { func (s *APIV1Service) DeleteUserNotification(ctx context.Context, request *v1pb.DeleteUserNotificationRequest) (*emptypb.Empty, error) {
notificationID, err := ExtractNotificationIDFromName(request.Name) user, notificationID, err := s.resolveUserAndNotificationIDFromName(ctx, request.Name)
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid notification name: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid notification name: %v", err)
} }
...@@ -1371,6 +1384,9 @@ func (s *APIV1Service) DeleteUserNotification(ctx context.Context, request *v1pb ...@@ -1371,6 +1384,9 @@ func (s *APIV1Service) DeleteUserNotification(ctx context.Context, request *v1pb
if currentUser == nil { if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated") return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
} }
if currentUser.ID != user.ID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
// Verify ownership before deletion // Verify ownership before deletion
inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{ inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{
ID: &notificationID, ID: &notificationID,
...@@ -1398,9 +1414,26 @@ func (s *APIV1Service) DeleteUserNotification(ctx context.Context, request *v1pb ...@@ -1398,9 +1414,26 @@ func (s *APIV1Service) DeleteUserNotification(ctx context.Context, request *v1pb
// convertInboxToUserNotification converts a storage-layer inbox to an API notification. // convertInboxToUserNotification converts a storage-layer inbox to an API notification.
// This handles the mapping between the internal inbox representation and the public API. // This handles the mapping between the internal inbox representation and the public API.
func (s *APIV1Service) convertInboxToUserNotification(ctx context.Context, inbox *store.Inbox) (*v1pb.UserNotification, error) { func (s *APIV1Service) convertInboxToUserNotification(ctx context.Context, inbox *store.Inbox) (*v1pb.UserNotification, error) {
usersByID, err := s.listUsersByID(ctx, []int32{inbox.ReceiverID, inbox.SenderID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list notification users: %v", err)
}
return s.convertInboxToUserNotificationWithUsers(ctx, inbox, usersByID)
}
func (s *APIV1Service) convertInboxToUserNotificationWithUsers(ctx context.Context, inbox *store.Inbox, usersByID map[int32]*store.User) (*v1pb.UserNotification, error) {
receiver := usersByID[inbox.ReceiverID]
if receiver == nil {
return nil, status.Errorf(codes.NotFound, "notification receiver not found")
}
sender := usersByID[inbox.SenderID]
if sender == nil {
return nil, status.Errorf(codes.NotFound, "notification sender not found")
}
notification := &v1pb.UserNotification{ notification := &v1pb.UserNotification{
Name: fmt.Sprintf("users/%d/notifications/%d", inbox.ReceiverID, inbox.ID), Name: fmt.Sprintf("%s/notifications/%d", BuildUserName(receiver.Username), inbox.ID),
Sender: fmt.Sprintf("%s%d", UserNamePrefix, inbox.SenderID), Sender: BuildUserName(sender.Username),
CreateTime: timestamppb.New(time.Unix(inbox.CreatedTs, 0)), CreateTime: timestamppb.New(time.Unix(inbox.CreatedTs, 0)),
} }
...@@ -1470,20 +1503,3 @@ func (s *APIV1Service) convertUserNotificationPayload(ctx context.Context, messa ...@@ -1470,20 +1503,3 @@ func (s *APIV1Service) convertUserNotificationPayload(ctx context.Context, messa
RelatedMemo: fmt.Sprintf("%s%s", MemoNamePrefix, relatedMemo.UID), RelatedMemo: fmt.Sprintf("%s%s", MemoNamePrefix, relatedMemo.UID),
}, nil }, nil
} }
// ExtractNotificationIDFromName extracts the notification ID from a resource name.
// Expected format: users/{user_id}/notifications/{notification_id}.
func ExtractNotificationIDFromName(name string) (int32, error) {
pattern := regexp.MustCompile(`^users/(\d+)/notifications/(\d+)$`)
matches := pattern.FindStringSubmatch(name)
if len(matches) != 3 {
return 0, errors.Errorf("invalid notification name: %s", name)
}
id, err := strconv.Atoi(matches[2])
if err != nil {
return 0, errors.Errorf("invalid notification id: %s", matches[2])
}
return int32(id), nil
}
...@@ -14,6 +14,46 @@ import ( ...@@ -14,6 +14,46 @@ import (
"github.com/usememos/memos/store" "github.com/usememos/memos/store"
) )
func (s *APIV1Service) listUsersByID(ctx context.Context, userIDs []int32) (map[int32]*store.User, error) {
if len(userIDs) == 0 {
return map[int32]*store.User{}, nil
}
uniqueUserIDs := make([]int32, 0, len(userIDs))
seenUserIDs := make(map[int32]struct{}, len(userIDs))
for _, userID := range userIDs {
if _, seen := seenUserIDs[userID]; seen {
continue
}
seenUserIDs[userID] = struct{}{}
uniqueUserIDs = append(uniqueUserIDs, userID)
}
users, err := s.Store.ListUsers(ctx, &store.FindUser{IDList: uniqueUserIDs})
if err != nil {
return nil, err
}
usersByID := make(map[int32]*store.User, len(users))
for _, user := range users {
usersByID[user.ID] = user
}
return usersByID, nil
}
func (s *APIV1Service) listUsernamesByID(ctx context.Context, userIDs []int32) (map[int32]string, error) {
usersByID, err := s.listUsersByID(ctx, userIDs)
if err != nil {
return nil, err
}
usernamesByID := make(map[int32]string, len(usersByID))
for _, user := range usersByID {
usernamesByID[user.ID] = user.Username
}
return usernamesByID, nil
}
func (s *APIV1Service) ListAllUserStats(ctx context.Context, _ *v1pb.ListAllUserStatsRequest) (*v1pb.ListAllUserStatsResponse, error) { func (s *APIV1Service) ListAllUserStats(ctx context.Context, _ *v1pb.ListAllUserStatsRequest) (*v1pb.ListAllUserStatsResponse, error) {
instanceMemoRelatedSetting, err := s.Store.GetInstanceMemoRelatedSetting(ctx) instanceMemoRelatedSetting, err := s.Store.GetInstanceMemoRelatedSetting(ctx)
if err != nil { if err != nil {
...@@ -44,6 +84,7 @@ func (s *APIV1Service) ListAllUserStats(ctx context.Context, _ *v1pb.ListAllUser ...@@ -44,6 +84,7 @@ func (s *APIV1Service) ListAllUserStats(ctx context.Context, _ *v1pb.ListAllUser
} }
userMemoStatMap := make(map[int32]*v1pb.UserStats) userMemoStatMap := make(map[int32]*v1pb.UserStats)
pinnedMemoIDsByUserID := make(map[int32][]int32)
limit := 1000 limit := 1000
offset := 0 offset := 0
memoFind.Limit = &limit memoFind.Limit = &limit
...@@ -62,7 +103,7 @@ func (s *APIV1Service) ListAllUserStats(ctx context.Context, _ *v1pb.ListAllUser ...@@ -62,7 +103,7 @@ func (s *APIV1Service) ListAllUserStats(ctx context.Context, _ *v1pb.ListAllUser
// Initialize user stats if not exists // Initialize user stats if not exists
if _, exists := userMemoStatMap[memo.CreatorID]; !exists { if _, exists := userMemoStatMap[memo.CreatorID]; !exists {
userMemoStatMap[memo.CreatorID] = &v1pb.UserStats{ userMemoStatMap[memo.CreatorID] = &v1pb.UserStats{
Name: fmt.Sprintf("users/%d/stats", memo.CreatorID), Name: "",
TagCount: make(map[string]int32), TagCount: make(map[string]int32),
MemoDisplayTimestamps: []*timestamppb.Timestamp{}, MemoDisplayTimestamps: []*timestamppb.Timestamp{},
PinnedMemos: []string{}, PinnedMemos: []string{},
...@@ -110,7 +151,7 @@ func (s *APIV1Service) ListAllUserStats(ctx context.Context, _ *v1pb.ListAllUser ...@@ -110,7 +151,7 @@ func (s *APIV1Service) ListAllUserStats(ctx context.Context, _ *v1pb.ListAllUser
// Track pinned memos // Track pinned memos
if memo.Pinned { if memo.Pinned {
stats.PinnedMemos = append(stats.PinnedMemos, fmt.Sprintf("users/%d/memos/%d", memo.CreatorID, memo.ID)) pinnedMemoIDsByUserID[memo.CreatorID] = append(pinnedMemoIDsByUserID[memo.CreatorID], memo.ID)
} }
} }
...@@ -118,7 +159,23 @@ func (s *APIV1Service) ListAllUserStats(ctx context.Context, _ *v1pb.ListAllUser ...@@ -118,7 +159,23 @@ func (s *APIV1Service) ListAllUserStats(ctx context.Context, _ *v1pb.ListAllUser
} }
userMemoStats := []*v1pb.UserStats{} userMemoStats := []*v1pb.UserStats{}
for _, userMemoStat := range userMemoStatMap { userIDs := make([]int32, 0, len(userMemoStatMap))
for userID := range userMemoStatMap {
userIDs = append(userIDs, userID)
}
usernamesByID, err := s.listUsernamesByID(ctx, userIDs)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
}
for userID, userMemoStat := range userMemoStatMap {
username, ok := usernamesByID[userID]
if !ok {
return nil, status.Errorf(codes.Internal, "failed to resolve user stats name")
}
userMemoStat.Name = fmt.Sprintf("%s/stats", BuildUserName(username))
for _, memoID := range pinnedMemoIDsByUserID[userID] {
userMemoStat.PinnedMemos = append(userMemoStat.PinnedMemos, fmt.Sprintf("%s/memos/%d", BuildUserName(username), memoID))
}
userMemoStats = append(userMemoStats, userMemoStat) userMemoStats = append(userMemoStats, userMemoStat)
} }
...@@ -129,10 +186,14 @@ func (s *APIV1Service) ListAllUserStats(ctx context.Context, _ *v1pb.ListAllUser ...@@ -129,10 +186,14 @@ func (s *APIV1Service) ListAllUserStats(ctx context.Context, _ *v1pb.ListAllUser
} }
func (s *APIV1Service) GetUserStats(ctx context.Context, request *v1pb.GetUserStatsRequest) (*v1pb.UserStats, error) { func (s *APIV1Service) GetUserStats(ctx context.Context, request *v1pb.GetUserStatsRequest) (*v1pb.UserStats, error) {
userID, err := ExtractUserIDFromName(request.Name) user, err := ResolveUserByName(ctx, s.Store, request.Name)
if err != nil { if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
} }
if user == nil {
return nil, status.Errorf(codes.NotFound, "user not found")
}
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx) currentUser, err := s.fetchCurrentUser(ctx)
if err != nil { if err != nil {
...@@ -211,7 +272,7 @@ func (s *APIV1Service) GetUserStats(ctx context.Context, request *v1pb.GetUserSt ...@@ -211,7 +272,7 @@ func (s *APIV1Service) GetUserStats(ctx context.Context, request *v1pb.GetUserSt
} }
} }
if memo.Pinned { if memo.Pinned {
pinnedMemos = append(pinnedMemos, fmt.Sprintf("users/%d/memos/%d", userID, memo.ID)) pinnedMemos = append(pinnedMemos, fmt.Sprintf("%s/memos/%d", BuildUserName(user.Username), memo.ID))
} }
} }
...@@ -219,7 +280,7 @@ func (s *APIV1Service) GetUserStats(ctx context.Context, request *v1pb.GetUserSt ...@@ -219,7 +280,7 @@ func (s *APIV1Service) GetUserStats(ctx context.Context, request *v1pb.GetUserSt
} }
userStats := &v1pb.UserStats{ userStats := &v1pb.UserStats{
Name: fmt.Sprintf("users/%d/stats", userID), Name: fmt.Sprintf("%s/stats", BuildUserName(user.Username)),
MemoDisplayTimestamps: displayTimestamps, MemoDisplayTimestamps: displayTimestamps,
TagCount: tagCount, TagCount: tagCount,
PinnedMemos: pinnedMemos, PinnedMemos: pinnedMemos,
......
...@@ -286,9 +286,6 @@ See SAFARI_FIX.md for recommended test coverage. ...@@ -286,9 +286,6 @@ See SAFARI_FIX.md for recommended test coverage.
# Test attachment # Test attachment
curl "http://localhost:8081/file/attachments/{uid}/file.jpg" curl "http://localhost:8081/file/attachments/{uid}/file.jpg"
# Test avatar by ID
curl "http://localhost:8081/file/users/1/avatar"
# Test avatar by username # Test avatar by username
curl "http://localhost:8081/file/users/steven/avatar" curl "http://localhost:8081/file/users/steven/avatar"
......
...@@ -20,7 +20,6 @@ import ( ...@@ -20,7 +20,6 @@ import (
"golang.org/x/sync/semaphore" "golang.org/x/sync/semaphore"
"github.com/usememos/memos/internal/profile" "github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/internal/util"
"github.com/usememos/memos/plugin/storage/s3" "github.com/usememos/memos/plugin/storage/s3"
storepb "github.com/usememos/memos/proto/gen/store" storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/server/auth" "github.com/usememos/memos/server/auth"
...@@ -154,7 +153,7 @@ func (s *FileServerService) serveUserAvatar(c *echo.Context) error { ...@@ -154,7 +153,7 @@ func (s *FileServerService) serveUserAvatar(c *echo.Context) error {
ctx := c.Request().Context() ctx := c.Request().Context()
identifier := c.Param("identifier") identifier := c.Param("identifier")
user, err := s.getUserByIdentifier(ctx, identifier) user, err := s.getUserByUsername(ctx, identifier)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get user").Wrap(err) return echo.NewHTTPError(http.StatusInternalServerError, "failed to get user").Wrap(err)
} }
...@@ -530,11 +529,8 @@ func (s *FileServerService) getCurrentUser(ctx context.Context, c *echo.Context) ...@@ -530,11 +529,8 @@ func (s *FileServerService) getCurrentUser(ctx context.Context, c *echo.Context)
return s.authenticator.AuthenticateToUser(ctx, authHeader, cookieHeader) return s.authenticator.AuthenticateToUser(ctx, authHeader, cookieHeader)
} }
// getUserByIdentifier finds a user by either ID or username. // getUserByUsername finds a user by username only.
func (s *FileServerService) getUserByIdentifier(ctx context.Context, identifier string) (*store.User, error) { func (s *FileServerService) getUserByUsername(ctx context.Context, identifier string) (*store.User, error) {
if userID, err := util.ConvertStringToInt32(identifier); err == nil {
return s.Store.GetUser(ctx, &store.FindUser{ID: &userID})
}
return s.Store.GetUser(ctx, &store.FindUser{Username: &identifier}) return s.Store.GetUser(ctx, &store.FindUser{Username: &identifier})
} }
......
...@@ -48,7 +48,10 @@ func (s *MCPService) handleReadMemoResource(ctx context.Context, req mcp.ReadRes ...@@ -48,7 +48,10 @@ func (s *MCPService) handleReadMemoResource(ctx context.Context, req mcp.ReadRes
return nil, err return nil, err
} }
j := storeMemoToJSON(memo) j, err := storeMemoToJSONWithStore(ctx, s.store, memo)
if err != nil {
return nil, errors.Wrap(err, "failed to resolve memo creator")
}
text := formatMemoMarkdown(j) text := formatMemoMarkdown(j)
return []mcp.ResourceContents{ return []mcp.ResourceContents{
......
...@@ -26,10 +26,14 @@ type attachmentJSON struct { ...@@ -26,10 +26,14 @@ type attachmentJSON struct {
Memo string `json:"memo,omitempty"` Memo string `json:"memo,omitempty"`
} }
func storeAttachmentToJSON(a *store.Attachment) attachmentJSON { func storeAttachmentToJSON(ctx context.Context, stores *store.Store, a *store.Attachment) (attachmentJSON, error) {
creator, err := lookupUsername(ctx, stores, a.CreatorID)
if err != nil {
return attachmentJSON{}, errors.Wrap(err, "lookup attachment creator username")
}
j := attachmentJSON{ j := attachmentJSON{
Name: "attachments/" + a.UID, Name: "attachments/" + a.UID,
Creator: fmt.Sprintf("users/%d", a.CreatorID), Creator: creator,
CreateTime: a.CreatedTs, CreateTime: a.CreatedTs,
Filename: a.Filename, Filename: a.Filename,
Type: a.Type, Type: a.Type,
...@@ -50,7 +54,38 @@ func storeAttachmentToJSON(a *store.Attachment) attachmentJSON { ...@@ -50,7 +54,38 @@ func storeAttachmentToJSON(a *store.Attachment) attachmentJSON {
if a.MemoUID != nil && *a.MemoUID != "" { if a.MemoUID != nil && *a.MemoUID != "" {
j.Memo = "memos/" + *a.MemoUID j.Memo = "memos/" + *a.MemoUID
} }
return j return j, nil
}
func storeAttachmentToJSONWithUsernames(a *store.Attachment, usernamesByID map[int32]string) (attachmentJSON, error) {
creator, err := lookupUsernameFromCache(usernamesByID, a.CreatorID)
if err != nil {
return attachmentJSON{}, errors.Wrap(err, "lookup attachment creator username from cache")
}
j := attachmentJSON{
Name: "attachments/" + a.UID,
Creator: creator,
CreateTime: a.CreatedTs,
Filename: a.Filename,
Type: a.Type,
Size: a.Size,
}
switch a.StorageType {
case storepb.AttachmentStorageType_LOCAL:
j.StorageType = "LOCAL"
case storepb.AttachmentStorageType_S3:
j.StorageType = "S3"
j.ExternalLink = a.Reference
case storepb.AttachmentStorageType_EXTERNAL:
j.StorageType = "EXTERNAL"
j.ExternalLink = a.Reference
default:
j.StorageType = "DATABASE"
}
if a.MemoUID != nil && *a.MemoUID != "" {
j.Memo = "memos/" + *a.MemoUID
}
return j, nil
} }
func parseAttachmentUID(name string) (string, error) { func parseAttachmentUID(name string) (string, error) {
...@@ -136,10 +171,22 @@ func (s *MCPService) handleListAttachments(ctx context.Context, req mcp.CallTool ...@@ -136,10 +171,22 @@ func (s *MCPService) handleListAttachments(ctx context.Context, req mcp.CallTool
if hasMore { if hasMore {
attachments = attachments[:pageSize] attachments = attachments[:pageSize]
} }
creatorIDs := make([]int32, 0, len(attachments))
for _, attachment := range attachments {
creatorIDs = append(creatorIDs, attachment.CreatorID)
}
usernamesByID, err := preloadUsernames(ctx, s.store, creatorIDs)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to preload attachment creators: %v", err)), nil
}
results := make([]attachmentJSON, len(attachments)) results := make([]attachmentJSON, len(attachments))
for i, a := range attachments { for i, a := range attachments {
results[i] = storeAttachmentToJSON(a) result, err := storeAttachmentToJSONWithUsernames(a, usernamesByID)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to resolve attachment creator: %v", err)), nil
}
results[i] = result
} }
type listResponse struct { type listResponse struct {
...@@ -186,7 +233,11 @@ func (s *MCPService) handleGetAttachment(ctx context.Context, req mcp.CallToolRe ...@@ -186,7 +233,11 @@ func (s *MCPService) handleGetAttachment(ctx context.Context, req mcp.CallToolRe
} }
} }
out, err := marshalJSON(storeAttachmentToJSON(attachment)) result, err := storeAttachmentToJSON(ctx, s.store, attachment)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to resolve attachment creator: %v", err)), nil
}
out, err := marshalJSON(result)
if err != nil { if err != nil {
return nil, err return nil, err
} }
...@@ -264,7 +315,11 @@ func (s *MCPService) handleLinkAttachmentToMemo(ctx context.Context, req mcp.Cal ...@@ -264,7 +315,11 @@ func (s *MCPService) handleLinkAttachmentToMemo(ctx context.Context, req mcp.Cal
if err != nil { if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to fetch updated attachment: %v", err)), nil return mcp.NewToolResultError(fmt.Sprintf("failed to fetch updated attachment: %v", err)), nil
} }
out, err := marshalJSON(storeAttachmentToJSON(updated)) result, err := storeAttachmentToJSON(ctx, s.store, updated)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to resolve attachment creator: %v", err)), nil
}
out, err := marshalJSON(result)
if err != nil { if err != nil {
return nil, err return nil, err
} }
......
...@@ -75,7 +75,6 @@ type memoJSON struct { ...@@ -75,7 +75,6 @@ type memoJSON struct {
func storeMemoToJSON(m *store.Memo) memoJSON { func storeMemoToJSON(m *store.Memo) memoJSON {
j := memoJSON{ j := memoJSON{
Name: "memos/" + m.UID, Name: "memos/" + m.UID,
Creator: fmt.Sprintf("users/%d", m.CreatorID),
CreateTime: m.CreatedTs, CreateTime: m.CreatedTs,
UpdateTime: m.UpdatedTs, UpdateTime: m.UpdatedTs,
Content: m.Content, Content: m.Content,
...@@ -103,6 +102,72 @@ func storeMemoToJSON(m *store.Memo) memoJSON { ...@@ -103,6 +102,72 @@ func storeMemoToJSON(m *store.Memo) memoJSON {
return j return j
} }
func lookupUsername(ctx context.Context, stores *store.Store, userID int32) (string, error) {
user, err := stores.GetUser(ctx, &store.FindUser{ID: &userID})
if err != nil {
return "", errors.Wrapf(err, "failed to get creator user %d", userID)
}
if user == nil {
return "", errors.Errorf("creator user %d not found", userID)
}
return "users/" + user.Username, nil
}
func preloadUsernames(ctx context.Context, stores *store.Store, userIDs []int32) (map[int32]string, error) {
if len(userIDs) == 0 {
return map[int32]string{}, nil
}
uniqueUserIDs := make([]int32, 0, len(userIDs))
seenUserIDs := make(map[int32]struct{}, len(userIDs))
for _, userID := range userIDs {
if _, seen := seenUserIDs[userID]; seen {
continue
}
seenUserIDs[userID] = struct{}{}
uniqueUserIDs = append(uniqueUserIDs, userID)
}
users, err := stores.ListUsers(ctx, &store.FindUser{IDList: uniqueUserIDs})
if err != nil {
return nil, errors.Wrap(err, "failed to list creator users")
}
usernamesByID := make(map[int32]string, len(users))
for _, user := range users {
usernamesByID[user.ID] = "users/" + user.Username
}
return usernamesByID, nil
}
func lookupUsernameFromCache(usernamesByID map[int32]string, userID int32) (string, error) {
username, ok := usernamesByID[userID]
if !ok {
return "", errors.Errorf("creator user %d not found", userID)
}
return username, nil
}
func storeMemoToJSONWithStore(ctx context.Context, stores *store.Store, m *store.Memo) (memoJSON, error) {
j := storeMemoToJSON(m)
creator, err := lookupUsername(ctx, stores, m.CreatorID)
if err != nil {
return memoJSON{}, err
}
j.Creator = creator
return j, nil
}
func storeMemoToJSONWithUsernames(m *store.Memo, usernamesByID map[int32]string) (memoJSON, error) {
j := storeMemoToJSON(m)
creator, err := lookupUsernameFromCache(usernamesByID, m.CreatorID)
if err != nil {
return memoJSON{}, err
}
j.Creator = creator
return j, nil
}
// checkMemoAccess returns an error if the caller cannot read memo. // checkMemoAccess returns an error if the caller cannot read memo.
// userID == 0 means anonymous. // userID == 0 means anonymous.
func checkMemoAccess(memo *store.Memo, userID int32) error { func checkMemoAccess(memo *store.Memo, userID int32) error {
...@@ -286,10 +351,22 @@ func (s *MCPService) handleListMemos(ctx context.Context, req mcp.CallToolReques ...@@ -286,10 +351,22 @@ func (s *MCPService) handleListMemos(ctx context.Context, req mcp.CallToolReques
if hasMore { if hasMore {
memos = memos[:pageSize] memos = memos[:pageSize]
} }
creatorIDs := make([]int32, 0, len(memos))
for _, memo := range memos {
creatorIDs = append(creatorIDs, memo.CreatorID)
}
usernamesByID, err := preloadUsernames(ctx, s.store, creatorIDs)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to preload memo creators: %v", err)), nil
}
results := make([]memoJSON, len(memos)) results := make([]memoJSON, len(memos))
for i, m := range memos { for i, m := range memos {
results[i] = storeMemoToJSON(m) result, err := storeMemoToJSONWithUsernames(m, usernamesByID)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to resolve memo creator: %v", err)), nil
}
results[i] = result
} }
type listResponse struct { type listResponse struct {
...@@ -322,7 +399,11 @@ func (s *MCPService) handleGetMemo(ctx context.Context, req mcp.CallToolRequest) ...@@ -322,7 +399,11 @@ func (s *MCPService) handleGetMemo(ctx context.Context, req mcp.CallToolRequest)
return mcp.NewToolResultError(err.Error()), nil return mcp.NewToolResultError(err.Error()), nil
} }
out, err := marshalJSON(storeMemoToJSON(memo)) result, err := storeMemoToJSONWithStore(ctx, s.store, memo)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to resolve memo creator: %v", err)), nil
}
out, err := marshalJSON(result)
if err != nil { if err != nil {
return nil, err return nil, err
} }
...@@ -355,7 +436,11 @@ func (s *MCPService) handleCreateMemo(ctx context.Context, req mcp.CallToolReque ...@@ -355,7 +436,11 @@ func (s *MCPService) handleCreateMemo(ctx context.Context, req mcp.CallToolReque
return mcp.NewToolResultError(fmt.Sprintf("failed to create memo: %v", err)), nil return mcp.NewToolResultError(fmt.Sprintf("failed to create memo: %v", err)), nil
} }
out, err := marshalJSON(storeMemoToJSON(memo)) result, err := storeMemoToJSONWithStore(ctx, s.store, memo)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to resolve memo creator: %v", err)), nil
}
out, err := marshalJSON(result)
if err != nil { if err != nil {
return nil, err return nil, err
} }
...@@ -419,7 +504,11 @@ func (s *MCPService) handleUpdateMemo(ctx context.Context, req mcp.CallToolReque ...@@ -419,7 +504,11 @@ func (s *MCPService) handleUpdateMemo(ctx context.Context, req mcp.CallToolReque
return mcp.NewToolResultError(fmt.Sprintf("failed to fetch updated memo: %v", err)), nil return mcp.NewToolResultError(fmt.Sprintf("failed to fetch updated memo: %v", err)), nil
} }
out, err := marshalJSON(storeMemoToJSON(updated)) result, err := storeMemoToJSONWithStore(ctx, s.store, updated)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to resolve memo creator: %v", err)), nil
}
out, err := marshalJSON(result)
if err != nil { if err != nil {
return nil, err return nil, err
} }
...@@ -478,10 +567,22 @@ func (s *MCPService) handleSearchMemos(ctx context.Context, req mcp.CallToolRequ ...@@ -478,10 +567,22 @@ func (s *MCPService) handleSearchMemos(ctx context.Context, req mcp.CallToolRequ
if err != nil { if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to search memos: %v", err)), nil return mcp.NewToolResultError(fmt.Sprintf("failed to search memos: %v", err)), nil
} }
creatorIDs := make([]int32, 0, len(memos))
for _, memo := range memos {
creatorIDs = append(creatorIDs, memo.CreatorID)
}
usernamesByID, err := preloadUsernames(ctx, s.store, creatorIDs)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to preload memo creators: %v", err)), nil
}
results := make([]memoJSON, len(memos)) results := make([]memoJSON, len(memos))
for i, m := range memos { for i, m := range memos {
results[i] = storeMemoToJSON(m) result, err := storeMemoToJSONWithUsernames(m, usernamesByID)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to resolve memo creator: %v", err)), nil
}
results[i] = result
} }
out, err := marshalJSON(results) out, err := marshalJSON(results)
if err != nil { if err != nil {
...@@ -531,11 +632,25 @@ func (s *MCPService) handleListMemoComments(ctx context.Context, req mcp.CallToo ...@@ -531,11 +632,25 @@ func (s *MCPService) handleListMemoComments(ctx context.Context, req mcp.CallToo
if err != nil { if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to list comments: %v", err)), nil return mcp.NewToolResultError(fmt.Sprintf("failed to list comments: %v", err)), nil
} }
creatorIDs := make([]int32, 0, len(memos))
for _, memo := range memos {
if checkMemoAccess(memo, userID) == nil {
creatorIDs = append(creatorIDs, memo.CreatorID)
}
}
usernamesByID, err := preloadUsernames(ctx, s.store, creatorIDs)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to preload memo creators: %v", err)), nil
}
results := make([]memoJSON, 0, len(memos)) results := make([]memoJSON, 0, len(memos))
for _, m := range memos { for _, m := range memos {
if checkMemoAccess(m, userID) == nil { if checkMemoAccess(m, userID) == nil {
results = append(results, storeMemoToJSON(m)) result, err := storeMemoToJSONWithUsernames(m, usernamesByID)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to resolve memo creator: %v", err)), nil
}
results = append(results, result)
} }
} }
out, err := marshalJSON(results) out, err := marshalJSON(results)
...@@ -591,7 +706,11 @@ func (s *MCPService) handleCreateMemoComment(ctx context.Context, req mcp.CallTo ...@@ -591,7 +706,11 @@ func (s *MCPService) handleCreateMemoComment(ctx context.Context, req mcp.CallTo
return mcp.NewToolResultError(fmt.Sprintf("failed to link comment: %v", err)), nil return mcp.NewToolResultError(fmt.Sprintf("failed to link comment: %v", err)), nil
} }
out, err := marshalJSON(storeMemoToJSON(comment)) result, err := storeMemoToJSONWithStore(ctx, s.store, comment)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to resolve memo creator: %v", err)), nil
}
out, err := marshalJSON(result)
if err != nil { if err != nil {
return nil, err return nil, err
} }
......
...@@ -60,12 +60,24 @@ func (s *MCPService) handleListReactions(ctx context.Context, req mcp.CallToolRe ...@@ -60,12 +60,24 @@ func (s *MCPService) handleListReactions(ctx context.Context, req mcp.CallToolRe
if err != nil { if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to list reactions: %v", err)), nil return mcp.NewToolResultError(fmt.Sprintf("failed to list reactions: %v", err)), nil
} }
creatorIDs := make([]int32, 0, len(reactions))
for _, reaction := range reactions {
creatorIDs = append(creatorIDs, reaction.CreatorID)
}
usernamesByID, err := preloadUsernames(ctx, s.store, creatorIDs)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to preload reaction creators: %v", err)), nil
}
results := make([]reactionJSON, len(reactions)) results := make([]reactionJSON, len(reactions))
for i, r := range reactions { for i, r := range reactions {
creator, err := lookupUsernameFromCache(usernamesByID, r.CreatorID)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to resolve reaction creator: %v", err)), nil
}
results[i] = reactionJSON{ results[i] = reactionJSON{
ID: r.ID, ID: r.ID,
Creator: fmt.Sprintf("users/%d", r.CreatorID), Creator: creator,
ReactionType: r.ReactionType, ReactionType: r.ReactionType,
CreateTime: r.CreatedTs, CreateTime: r.CreatedTs,
} }
...@@ -130,9 +142,13 @@ func (s *MCPService) handleUpsertReaction(ctx context.Context, req mcp.CallToolR ...@@ -130,9 +142,13 @@ func (s *MCPService) handleUpsertReaction(ctx context.Context, req mcp.CallToolR
return mcp.NewToolResultError(fmt.Sprintf("failed to upsert reaction: %v", err)), nil return mcp.NewToolResultError(fmt.Sprintf("failed to upsert reaction: %v", err)), nil
} }
creator, err := lookupUsername(ctx, s.store, reaction.CreatorID)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to resolve reaction creator: %v", err)), nil
}
out, err := marshalJSON(reactionJSON{ out, err := marshalJSON(reactionJSON{
ID: reaction.ID, ID: reaction.ID,
Creator: fmt.Sprintf("users/%d", reaction.CreatorID), Creator: creator,
ReactionType: reaction.ReactionType, ReactionType: reaction.ReactionType,
CreateTime: reaction.CreatedTs, CreateTime: reaction.CreatedTs,
}) })
......
...@@ -145,6 +145,7 @@ func (d *DB) ListMemos(ctx context.Context, find *store.FindMemo) ([]*store.Memo ...@@ -145,6 +145,7 @@ func (d *DB) ListMemos(ctx context.Context, find *store.FindMemo) ([]*store.Memo
} }
query := "SELECT " + strings.Join(fields, ", ") + " FROM `memo`" + " " + query := "SELECT " + strings.Join(fields, ", ") + " FROM `memo`" + " " +
"LEFT JOIN `user` AS `memo_creator` ON `memo`.`creator_id` = `memo_creator`.`id`" + " " +
"LEFT JOIN `memo_relation` ON `memo`.`id` = `memo_relation`.`memo_id` AND `memo_relation`.`type` = 'COMMENT'" + " " + "LEFT JOIN `memo_relation` ON `memo`.`id` = `memo_relation`.`memo_id` AND `memo_relation`.`type` = 'COMMENT'" + " " +
"LEFT JOIN `memo` AS `parent_memo` ON `memo_relation`.`related_memo_id` = `parent_memo`.`id`" + " " + "LEFT JOIN `memo` AS `parent_memo` ON `memo_relation`.`related_memo_id` = `parent_memo`.`id`" + " " +
"WHERE " + strings.Join(where, " AND ") + " " + "WHERE " + strings.Join(where, " AND ") + " " +
......
...@@ -131,6 +131,7 @@ func (d *DB) ListMemos(ctx context.Context, find *store.FindMemo) ([]*store.Memo ...@@ -131,6 +131,7 @@ func (d *DB) ListMemos(ctx context.Context, find *store.FindMemo) ([]*store.Memo
query := `SELECT ` + strings.Join(fields, ", ") + ` query := `SELECT ` + strings.Join(fields, ", ") + `
FROM memo FROM memo
LEFT JOIN "user" AS memo_creator ON memo.creator_id = memo_creator.id
LEFT JOIN memo_relation ON memo.id = memo_relation.memo_id AND memo_relation.type = 'COMMENT' LEFT JOIN memo_relation ON memo.id = memo_relation.memo_id AND memo_relation.type = 'COMMENT'
LEFT JOIN memo AS parent_memo ON memo_relation.related_memo_id = parent_memo.id LEFT JOIN memo AS parent_memo ON memo_relation.related_memo_id = parent_memo.id
WHERE ` + strings.Join(where, " AND ") + ` WHERE ` + strings.Join(where, " AND ") + `
......
...@@ -137,6 +137,7 @@ func (d *DB) ListMemos(ctx context.Context, find *store.FindMemo) ([]*store.Memo ...@@ -137,6 +137,7 @@ func (d *DB) ListMemos(ctx context.Context, find *store.FindMemo) ([]*store.Memo
} }
query := "SELECT " + strings.Join(fields, ", ") + "FROM `memo` " + query := "SELECT " + strings.Join(fields, ", ") + "FROM `memo` " +
"LEFT JOIN `user` AS `memo_creator` ON `memo`.`creator_id` = `memo_creator`.`id` " +
"LEFT JOIN `memo_relation` ON `memo`.`id` = `memo_relation`.`memo_id` AND `memo_relation`.`type` = \"COMMENT\" " + "LEFT JOIN `memo_relation` ON `memo`.`id` = `memo_relation`.`memo_id` AND `memo_relation`.`type` = \"COMMENT\" " +
"LEFT JOIN `memo` AS `parent_memo` ON `memo_relation`.`related_memo_id` = `parent_memo`.`id` " + "LEFT JOIN `memo` AS `parent_memo` ON `memo_relation`.`related_memo_id` = `parent_memo`.`id` " +
"WHERE " + strings.Join(where, " AND ") + " " + "WHERE " + strings.Join(where, " AND ") + " " +
......
...@@ -184,6 +184,32 @@ func TestMemoFilterPinnedPredicate(t *testing.T) { ...@@ -184,6 +184,32 @@ func TestMemoFilterPinnedPredicate(t *testing.T) {
require.True(t, memos[0].Pinned) require.True(t, memos[0].Pinned)
} }
// =============================================================================
// Creator Field Tests
// Schema: creator (string resource name), creator_id (int, ==, !=)
// =============================================================================
func TestMemoFilterCreatorEquals(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
user2, err := tc.Store.CreateUser(tc.Ctx, &store.User{
Username: "user2",
Role: store.RoleUser,
Email: "user2@example.com",
Nickname: "User 2",
})
require.NoError(t, err)
tc.CreateMemo(NewMemoBuilder("memo-user1", tc.User.ID).Content("User 1 memo"))
tc.CreateMemo(NewMemoBuilder("memo-user2", user2.ID).Content("User 2 memo"))
memos := tc.ListWithFilter(`creator == "users/` + tc.User.Username + `"`)
require.Len(t, memos, 1)
require.Equal(t, tc.User.ID, memos[0].CreatorID)
}
// ============================================================================= // =============================================================================
// Creator ID Field Tests // Creator ID Field Tests
// Schema: creator_id (int, ==, !=) // Schema: creator_id (int, ==, !=)
......
...@@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react"; ...@@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react";
import useDebounce from "react-use/lib/useDebounce"; import useDebounce from "react-use/lib/useDebounce";
import { memoServiceClient } from "@/connect"; import { memoServiceClient } from "@/connect";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts"; import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import { extractUserIdFromName } from "@/helpers/resource-names"; import { buildMemoCreatorFilter } from "@/helpers/resource-names";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { import {
type Memo, type Memo,
...@@ -44,7 +44,11 @@ export const useLinkMemo = ({ isOpen, currentMemoName, existingRelations, onAddR ...@@ -44,7 +44,11 @@ export const useLinkMemo = ({ isOpen, currentMemoName, existingRelations, onAddR
setIsFetching(true); setIsFetching(true);
try { try {
const conditions = [`creator_id == ${extractUserIdFromName(user?.name ?? "")}`]; const conditions: string[] = [];
const creatorFilter = buildMemoCreatorFilter(user?.name ?? "");
if (creatorFilter) {
conditions.push(creatorFilter);
}
if (searchText) { if (searchText) {
conditions.push(`content.contains("${searchText}")`); conditions.push(`content.contains("${searchText}")`);
} }
......
...@@ -15,7 +15,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge ...@@ -15,7 +15,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)$/u; const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)$/u;
// Helper function to extract shortcut ID from resource name // Helper function to extract shortcut ID from resource name
// Format: users/{user}/shortcuts/{shortcut} // Format: users/{username}/shortcuts/{shortcut}
const getShortcutId = (name: string): string => { const getShortcutId = (name: string): string => {
const parts = name.split("/"); const parts = name.split("/");
return parts.length === 4 ? parts[3] : ""; return parts.length === 4 ? parts[3] : "";
......
...@@ -8,6 +8,7 @@ import { MapContainer, Marker, Popup, useMap } from "react-leaflet"; ...@@ -8,6 +8,7 @@ import { MapContainer, Marker, Popup, useMap } from "react-leaflet";
import MarkerClusterGroup from "react-leaflet-cluster"; import MarkerClusterGroup from "react-leaflet-cluster";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { defaultMarkerIcon, ThemedTileLayer } from "@/components/map/map-utils"; import { defaultMarkerIcon, ThemedTileLayer } from "@/components/map/map-utils";
import { buildMemoCreatorFilter } from "@/helpers/resource-names";
import { useInfiniteMemos } from "@/hooks/useMemoQueries"; import { useInfiniteMemos } from "@/hooks/useMemoQueries";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { State } from "@/types/proto/api/v1/common_pb"; import { State } from "@/types/proto/api/v1/common_pb";
...@@ -30,11 +31,6 @@ const createClusterCustomIcon = (cluster: ClusterGroup) => { ...@@ -30,11 +31,6 @@ const createClusterCustomIcon = (cluster: ClusterGroup) => {
}); });
}; };
const extractUserIdFromName = (name: string): string => {
const match = name.match(/users\/(\d+)/);
return match ? match[1] : "";
};
const MapFitBounds = ({ memos }: { memos: Memo[] }) => { const MapFitBounds = ({ memos }: { memos: Memo[] }) => {
const map = useMap(); const map = useMap();
...@@ -52,14 +48,17 @@ const MapFitBounds = ({ memos }: { memos: Memo[] }) => { ...@@ -52,14 +48,17 @@ const MapFitBounds = ({ memos }: { memos: Memo[] }) => {
}; };
const UserMemoMap = ({ creator, className }: Props) => { const UserMemoMap = ({ creator, className }: Props) => {
const creatorId = useMemo(() => extractUserIdFromName(creator), [creator]); const creatorFilter = useMemo(() => buildMemoCreatorFilter(creator), [creator]);
const { data, isLoading } = useInfiniteMemos({ const { data, isLoading } = useInfiniteMemos(
state: State.NORMAL, {
orderBy: "display_time desc", state: State.NORMAL,
pageSize: 1000, orderBy: "display_time desc",
filter: `creator_id == ${creatorId}`, pageSize: 1000,
}); filter: creatorFilter,
},
{ enabled: Boolean(creatorFilter) },
);
const memosWithLocation = useMemo(() => data?.pages.flatMap((page) => page.memos).filter((memo) => memo.location) || [], [data]); const memosWithLocation = useMemo(() => data?.pages.flatMap((page) => page.memos).filter((memo) => memo.location) || [], [data]);
......
...@@ -7,8 +7,12 @@ export const userNamePrefix = "users/"; ...@@ -7,8 +7,12 @@ export const userNamePrefix = "users/";
export const memoNamePrefix = "memos/"; export const memoNamePrefix = "memos/";
export const identityProviderNamePrefix = "identity-providers/"; export const identityProviderNamePrefix = "identity-providers/";
export const extractUserIdFromName = (name: string) => { export const buildMemoCreatorFilter = (name: string) => {
return name.split(userNamePrefix).pop() || ""; if (!name) {
return undefined;
}
const normalizedName = name.startsWith(userNamePrefix) ? name : `${userNamePrefix}${name}`;
return `creator == ${JSON.stringify(normalizedName)}`;
}; };
export const extractMemoIdFromName = (name: string) => { export const extractMemoIdFromName = (name: string) => {
......
...@@ -2,13 +2,9 @@ import { useMemo } from "react"; ...@@ -2,13 +2,9 @@ import { useMemo } from "react";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext"; import { useInstance } from "@/contexts/InstanceContext";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext"; import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { buildMemoCreatorFilter } from "@/helpers/resource-names";
import { Visibility } from "@/types/proto/api/v1/memo_service_pb"; import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
const extractUserIdFromName = (name: string): string => {
const match = name.match(/users\/(\d+)/);
return match ? match[1] : "";
};
const getVisibilityName = (visibility: Visibility): string => { const getVisibilityName = (visibility: Visibility): string => {
switch (visibility) { switch (visibility) {
case Visibility.PUBLIC: case Visibility.PUBLIC:
...@@ -27,6 +23,8 @@ const getShortcutId = (name: string): string => { ...@@ -27,6 +23,8 @@ const getShortcutId = (name: string): string => {
return parts.length === 4 ? parts[3] : ""; return parts.length === 4 ? parts[3] : "";
}; };
const escapeFilterValue = (value: string): string => JSON.stringify(value);
export interface UseMemoFiltersOptions { export interface UseMemoFiltersOptions {
creatorName?: string; creatorName?: string;
includeShortcuts?: boolean; includeShortcuts?: boolean;
...@@ -53,7 +51,10 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un ...@@ -53,7 +51,10 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un
// Add creator filter if provided // Add creator filter if provided
if (creatorName) { if (creatorName) {
conditions.push(`creator_id == ${extractUserIdFromName(creatorName)}`); const creatorFilter = buildMemoCreatorFilter(creatorName);
if (creatorFilter) {
conditions.push(creatorFilter);
}
} }
// Add shortcut filter if enabled and selected // Add shortcut filter if enabled and selected
...@@ -64,9 +65,9 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un ...@@ -64,9 +65,9 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un
// Add active filters from context // Add active filters from context
for (const filter of filters) { for (const filter of filters) {
if (filter.factor === "contentSearch") { if (filter.factor === "contentSearch") {
conditions.push(`content.contains("${filter.value}")`); conditions.push(`content.contains(${escapeFilterValue(filter.value)})`);
} else if (filter.factor === "tagSearch") { } else if (filter.factor === "tagSearch") {
conditions.push(`tag in ["${filter.value}"]`); conditions.push(`tag in [${escapeFilterValue(filter.value)}]`);
} else if (filter.factor === "pinned") { } else if (filter.factor === "pinned") {
if (includePinned) { if (includePinned) {
conditions.push(`pinned`); conditions.push(`pinned`);
......
...@@ -40,7 +40,7 @@ const MainLayout = () => { ...@@ -40,7 +40,7 @@ const MainLayout = () => {
if (match && context === "profile") { if (match && context === "profile") {
const username = match.params.username; const username = match.params.username;
if (username) { if (username) {
// Fetch or get user to obtain user name (e.g., "users/123") // Fetch or get user to obtain the canonical user name (e.g., "users/steven")
// Note: User stats will be fetched by useFilteredMemoStats // Note: User stats will be fetched by useFilteredMemoStats
userServiceClient userServiceClient
.getUser({ name: `users/${username}` }) .getUser({ name: `users/${username}` })
......
...@@ -16,7 +16,7 @@ import type { Message } from "@bufbuild/protobuf"; ...@@ -16,7 +16,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file api/v1/shortcut_service.proto. * Describes the file api/v1/shortcut_service.proto.
*/ */
export const file_api_v1_shortcut_service: GenFile = /*@__PURE__*/ export const file_api_v1_shortcut_service: GenFile = /*@__PURE__*/
fileDesc("Ch1hcGkvdjEvc2hvcnRjdXRfc2VydmljZS5wcm90bxIMbWVtb3MuYXBpLnYxIpoBCghTaG9ydGN1dBIRCgRuYW1lGAEgASgJQgPgQQgSEgoFdGl0bGUYAiABKAlCA+BBAhITCgZmaWx0ZXIYAyABKAlCA+BBATpS6kFPChVtZW1vcy5hcGkudjEvU2hvcnRjdXQSIXVzZXJzL3t1c2VyfS9zaG9ydGN1dHMve3Nob3J0Y3V0fSoJc2hvcnRjdXRzMghzaG9ydGN1dCJFChRMaXN0U2hvcnRjdXRzUmVxdWVzdBItCgZwYXJlbnQYASABKAlCHeBBAvpBFxIVbWVtb3MuYXBpLnYxL1Nob3J0Y3V0IkIKFUxpc3RTaG9ydGN1dHNSZXNwb25zZRIpCglzaG9ydGN1dHMYASADKAsyFi5tZW1vcy5hcGkudjEuU2hvcnRjdXQiQQoSR2V0U2hvcnRjdXRSZXF1ZXN0EisKBG5hbWUYASABKAlCHeBBAvpBFwoVbWVtb3MuYXBpLnYxL1Nob3J0Y3V0IpEBChVDcmVhdGVTaG9ydGN1dFJlcXVlc3QSLQoGcGFyZW50GAEgASgJQh3gQQL6QRcSFW1lbW9zLmFwaS52MS9TaG9ydGN1dBItCghzaG9ydGN1dBgCIAEoCzIWLm1lbW9zLmFwaS52MS5TaG9ydGN1dEID4EECEhoKDXZhbGlkYXRlX29ubHkYAyABKAhCA+BBASJ8ChVVcGRhdGVTaG9ydGN1dFJlcXVlc3QSLQoIc2hvcnRjdXQYASABKAsyFi5tZW1vcy5hcGkudjEuU2hvcnRjdXRCA+BBAhI0Cgt1cGRhdGVfbWFzaxgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE1hc2tCA+BBASJEChVEZWxldGVTaG9ydGN1dFJlcXVlc3QSKwoEbmFtZRgBIAEoCUId4EEC+kEXChVtZW1vcy5hcGkudjEvU2hvcnRjdXQy3gUKD1Nob3J0Y3V0U2VydmljZRKNAQoNTGlzdFNob3J0Y3V0cxIiLm1lbW9zLmFwaS52MS5MaXN0U2hvcnRjdXRzUmVxdWVzdBojLm1lbW9zLmFwaS52MS5MaXN0U2hvcnRjdXRzUmVzcG9uc2UiM9pBBnBhcmVudILT5JMCJBIiL2FwaS92MS97cGFyZW50PXVzZXJzLyp9L3Nob3J0Y3V0cxJ6CgtHZXRTaG9ydGN1dBIgLm1lbW9zLmFwaS52MS5HZXRTaG9ydGN1dFJlcXVlc3QaFi5tZW1vcy5hcGkudjEuU2hvcnRjdXQiMdpBBG5hbWWC0+STAiQSIi9hcGkvdjEve25hbWU9dXNlcnMvKi9zaG9ydGN1dHMvKn0SlQEKDkNyZWF0ZVNob3J0Y3V0EiMubWVtb3MuYXBpLnYxLkNyZWF0ZVNob3J0Y3V0UmVxdWVzdBoWLm1lbW9zLmFwaS52MS5TaG9ydGN1dCJG2kEPcGFyZW50LHNob3J0Y3V0gtPkkwIuOghzaG9ydGN1dCIiL2FwaS92MS97cGFyZW50PXVzZXJzLyp9L3Nob3J0Y3V0cxKjAQoOVXBkYXRlU2hvcnRjdXQSIy5tZW1vcy5hcGkudjEuVXBkYXRlU2hvcnRjdXRSZXF1ZXN0GhYubWVtb3MuYXBpLnYxLlNob3J0Y3V0IlTaQRRzaG9ydGN1dCx1cGRhdGVfbWFza4LT5JMCNzoIc2hvcnRjdXQyKy9hcGkvdjEve3Nob3J0Y3V0Lm5hbWU9dXNlcnMvKi9zaG9ydGN1dHMvKn0SgAEKDkRlbGV0ZVNob3J0Y3V0EiMubWVtb3MuYXBpLnYxLkRlbGV0ZVNob3J0Y3V0UmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIx2kEEbmFtZYLT5JMCJCoiL2FwaS92MS97bmFtZT11c2Vycy8qL3Nob3J0Y3V0cy8qfUKsAQoQY29tLm1lbW9zLmFwaS52MUIUU2hvcnRjdXRTZXJ2aWNlUHJvdG9QAVowZ2l0aHViLmNvbS91c2VtZW1vcy9tZW1vcy9wcm90by9nZW4vYXBpL3YxO2FwaXYxogIDTUFYqgIMTWVtb3MuQXBpLlYxygIMTWVtb3NcQXBpXFYx4gIYTWVtb3NcQXBpXFYxXEdQQk1ldGFkYXRh6gIOTWVtb3M6OkFwaTo6VjFiBnByb3RvMw", [file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_empty, file_google_protobuf_field_mask]); fileDesc("Ch1hcGkvdjEvc2hvcnRjdXRfc2VydmljZS5wcm90bxIMbWVtb3MuYXBpLnYxIp4BCghTaG9ydGN1dBIRCgRuYW1lGAEgASgJQgPgQQgSEgoFdGl0bGUYAiABKAlCA+BBAhITCgZmaWx0ZXIYAyABKAlCA+BBATpW6kFTChVtZW1vcy5hcGkudjEvU2hvcnRjdXQSJXVzZXJzL3t1c2VybmFtZX0vc2hvcnRjdXRzL3tzaG9ydGN1dH0qCXNob3J0Y3V0czIIc2hvcnRjdXQiRQoUTGlzdFNob3J0Y3V0c1JlcXVlc3QSLQoGcGFyZW50GAEgASgJQh3gQQL6QRcSFW1lbW9zLmFwaS52MS9TaG9ydGN1dCJCChVMaXN0U2hvcnRjdXRzUmVzcG9uc2USKQoJc2hvcnRjdXRzGAEgAygLMhYubWVtb3MuYXBpLnYxLlNob3J0Y3V0IkEKEkdldFNob3J0Y3V0UmVxdWVzdBIrCgRuYW1lGAEgASgJQh3gQQL6QRcKFW1lbW9zLmFwaS52MS9TaG9ydGN1dCKRAQoVQ3JlYXRlU2hvcnRjdXRSZXF1ZXN0Ei0KBnBhcmVudBgBIAEoCUId4EEC+kEXEhVtZW1vcy5hcGkudjEvU2hvcnRjdXQSLQoIc2hvcnRjdXQYAiABKAsyFi5tZW1vcy5hcGkudjEuU2hvcnRjdXRCA+BBAhIaCg12YWxpZGF0ZV9vbmx5GAMgASgIQgPgQQEifAoVVXBkYXRlU2hvcnRjdXRSZXF1ZXN0Ei0KCHNob3J0Y3V0GAEgASgLMhYubWVtb3MuYXBpLnYxLlNob3J0Y3V0QgPgQQISNAoLdXBkYXRlX21hc2sYAiABKAsyGi5nb29nbGUucHJvdG9idWYuRmllbGRNYXNrQgPgQQEiRAoVRGVsZXRlU2hvcnRjdXRSZXF1ZXN0EisKBG5hbWUYASABKAlCHeBBAvpBFwoVbWVtb3MuYXBpLnYxL1Nob3J0Y3V0Mt4FCg9TaG9ydGN1dFNlcnZpY2USjQEKDUxpc3RTaG9ydGN1dHMSIi5tZW1vcy5hcGkudjEuTGlzdFNob3J0Y3V0c1JlcXVlc3QaIy5tZW1vcy5hcGkudjEuTGlzdFNob3J0Y3V0c1Jlc3BvbnNlIjPaQQZwYXJlbnSC0+STAiQSIi9hcGkvdjEve3BhcmVudD11c2Vycy8qfS9zaG9ydGN1dHMSegoLR2V0U2hvcnRjdXQSIC5tZW1vcy5hcGkudjEuR2V0U2hvcnRjdXRSZXF1ZXN0GhYubWVtb3MuYXBpLnYxLlNob3J0Y3V0IjHaQQRuYW1lgtPkkwIkEiIvYXBpL3YxL3tuYW1lPXVzZXJzLyovc2hvcnRjdXRzLyp9EpUBCg5DcmVhdGVTaG9ydGN1dBIjLm1lbW9zLmFwaS52MS5DcmVhdGVTaG9ydGN1dFJlcXVlc3QaFi5tZW1vcy5hcGkudjEuU2hvcnRjdXQiRtpBD3BhcmVudCxzaG9ydGN1dILT5JMCLjoIc2hvcnRjdXQiIi9hcGkvdjEve3BhcmVudD11c2Vycy8qfS9zaG9ydGN1dHMSowEKDlVwZGF0ZVNob3J0Y3V0EiMubWVtb3MuYXBpLnYxLlVwZGF0ZVNob3J0Y3V0UmVxdWVzdBoWLm1lbW9zLmFwaS52MS5TaG9ydGN1dCJU2kEUc2hvcnRjdXQsdXBkYXRlX21hc2uC0+STAjc6CHNob3J0Y3V0MisvYXBpL3YxL3tzaG9ydGN1dC5uYW1lPXVzZXJzLyovc2hvcnRjdXRzLyp9EoABCg5EZWxldGVTaG9ydGN1dBIjLm1lbW9zLmFwaS52MS5EZWxldGVTaG9ydGN1dFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiMdpBBG5hbWWC0+STAiQqIi9hcGkvdjEve25hbWU9dXNlcnMvKi9zaG9ydGN1dHMvKn1CrAEKEGNvbS5tZW1vcy5hcGkudjFCFFNob3J0Y3V0U2VydmljZVByb3RvUAFaMGdpdGh1Yi5jb20vdXNlbWVtb3MvbWVtb3MvcHJvdG8vZ2VuL2FwaS92MTthcGl2MaICA01BWKoCDE1lbW9zLkFwaS5WMcoCDE1lbW9zXEFwaVxWMeICGE1lbW9zXEFwaVxWMVxHUEJNZXRhZGF0YeoCDk1lbW9zOjpBcGk6OlYxYgZwcm90bzM", [file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_empty, file_google_protobuf_field_mask]);
/** /**
* @generated from message memos.api.v1.Shortcut * @generated from message memos.api.v1.Shortcut
...@@ -24,7 +24,7 @@ export const file_api_v1_shortcut_service: GenFile = /*@__PURE__*/ ...@@ -24,7 +24,7 @@ export const file_api_v1_shortcut_service: GenFile = /*@__PURE__*/
export type Shortcut = Message<"memos.api.v1.Shortcut"> & { export type Shortcut = Message<"memos.api.v1.Shortcut"> & {
/** /**
* The resource name of the shortcut. * The resource name of the shortcut.
* Format: users/{user}/shortcuts/{shortcut} * Format: users/{username}/shortcuts/{shortcut}
* *
* @generated from field: string name = 1; * @generated from field: string name = 1;
*/ */
...@@ -58,7 +58,7 @@ export const ShortcutSchema: GenMessage<Shortcut> = /*@__PURE__*/ ...@@ -58,7 +58,7 @@ export const ShortcutSchema: GenMessage<Shortcut> = /*@__PURE__*/
export type ListShortcutsRequest = Message<"memos.api.v1.ListShortcutsRequest"> & { export type ListShortcutsRequest = Message<"memos.api.v1.ListShortcutsRequest"> & {
/** /**
* Required. The parent resource where shortcuts are listed. * Required. The parent resource where shortcuts are listed.
* Format: users/{user} * Format: users/{username}
* *
* @generated from field: string parent = 1; * @generated from field: string parent = 1;
*/ */
...@@ -97,7 +97,7 @@ export const ListShortcutsResponseSchema: GenMessage<ListShortcutsResponse> = /* ...@@ -97,7 +97,7 @@ export const ListShortcutsResponseSchema: GenMessage<ListShortcutsResponse> = /*
export type GetShortcutRequest = Message<"memos.api.v1.GetShortcutRequest"> & { export type GetShortcutRequest = Message<"memos.api.v1.GetShortcutRequest"> & {
/** /**
* Required. The resource name of the shortcut to retrieve. * Required. The resource name of the shortcut to retrieve.
* Format: users/{user}/shortcuts/{shortcut} * Format: users/{username}/shortcuts/{shortcut}
* *
* @generated from field: string name = 1; * @generated from field: string name = 1;
*/ */
...@@ -117,7 +117,7 @@ export const GetShortcutRequestSchema: GenMessage<GetShortcutRequest> = /*@__PUR ...@@ -117,7 +117,7 @@ export const GetShortcutRequestSchema: GenMessage<GetShortcutRequest> = /*@__PUR
export type CreateShortcutRequest = Message<"memos.api.v1.CreateShortcutRequest"> & { export type CreateShortcutRequest = Message<"memos.api.v1.CreateShortcutRequest"> & {
/** /**
* Required. The parent resource where this shortcut will be created. * Required. The parent resource where this shortcut will be created.
* Format: users/{user} * Format: users/{username}
* *
* @generated from field: string parent = 1; * @generated from field: string parent = 1;
*/ */
...@@ -177,7 +177,7 @@ export const UpdateShortcutRequestSchema: GenMessage<UpdateShortcutRequest> = /* ...@@ -177,7 +177,7 @@ export const UpdateShortcutRequestSchema: GenMessage<UpdateShortcutRequest> = /*
export type DeleteShortcutRequest = Message<"memos.api.v1.DeleteShortcutRequest"> & { export type DeleteShortcutRequest = Message<"memos.api.v1.DeleteShortcutRequest"> & {
/** /**
* Required. The resource name of the shortcut to delete. * Required. The resource name of the shortcut to delete.
* Format: users/{user}/shortcuts/{shortcut} * Format: users/{username}/shortcuts/{shortcut}
* *
* @generated from field: string name = 1; * @generated from field: string name = 1;
*/ */
......
...@@ -18,7 +18,7 @@ import type { Message } from "@bufbuild/protobuf"; ...@@ -18,7 +18,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file api/v1/user_service.proto. * Describes the file api/v1/user_service.proto.
*/ */
export const file_api_v1_user_service: GenFile = /*@__PURE__*/ export const file_api_v1_user_service: GenFile = /*@__PURE__*/
fileDesc("ChlhcGkvdjEvdXNlcl9zZXJ2aWNlLnByb3RvEgxtZW1vcy5hcGkudjEi1gMKBFVzZXISEQoEbmFtZRgBIAEoCUID4EEIEioKBHJvbGUYAiABKA4yFy5tZW1vcy5hcGkudjEuVXNlci5Sb2xlQgPgQQISFQoIdXNlcm5hbWUYAyABKAlCA+BBAhISCgVlbWFpbBgEIAEoCUID4EEBEhkKDGRpc3BsYXlfbmFtZRgFIAEoCUID4EEBEhcKCmF2YXRhcl91cmwYBiABKAlCA+BBARIYCgtkZXNjcmlwdGlvbhgHIAEoCUID4EEBEhUKCHBhc3N3b3JkGAggASgJQgPgQQQSJwoFc3RhdGUYCSABKA4yEy5tZW1vcy5hcGkudjEuU3RhdGVCA+BBAhI0CgtjcmVhdGVfdGltZRgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAxI0Cgt1cGRhdGVfdGltZRgLIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAyIxCgRSb2xlEhQKEFJPTEVfVU5TUEVDSUZJRUQQABIJCgVBRE1JThACEggKBFVTRVIQAzo36kE0ChFtZW1vcy5hcGkudjEvVXNlchIMdXNlcnMve3VzZXJ9GgRuYW1lKgV1c2VyczIEdXNlciJzChBMaXN0VXNlcnNSZXF1ZXN0EhYKCXBhZ2Vfc2l6ZRgBIAEoBUID4EEBEhcKCnBhZ2VfdG9rZW4YAiABKAlCA+BBARITCgZmaWx0ZXIYAyABKAlCA+BBARIZCgxzaG93X2RlbGV0ZWQYBCABKAhCA+BBASJjChFMaXN0VXNlcnNSZXNwb25zZRIhCgV1c2VycxgBIAMoCzISLm1lbW9zLmFwaS52MS5Vc2VyEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCRISCgp0b3RhbF9zaXplGAMgASgFIm0KDkdldFVzZXJSZXF1ZXN0EicKBG5hbWUYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL1VzZXISMgoJcmVhZF9tYXNrGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLkZpZWxkTWFza0ID4EEBIogBChFDcmVhdGVVc2VyUmVxdWVzdBIoCgR1c2VyGAEgASgLMhIubWVtb3MuYXBpLnYxLlVzZXJCBuBBAuBBBBIUCgd1c2VyX2lkGAIgASgJQgPgQQESGgoNdmFsaWRhdGVfb25seRgDIAEoCEID4EEBEhcKCnJlcXVlc3RfaWQYBCABKAlCA+BBASKMAQoRVXBkYXRlVXNlclJlcXVlc3QSJQoEdXNlchgBIAEoCzISLm1lbW9zLmFwaS52MS5Vc2VyQgPgQQISNAoLdXBkYXRlX21hc2sYAiABKAsyGi5nb29nbGUucHJvdG9idWYuRmllbGRNYXNrQgPgQQISGgoNYWxsb3dfbWlzc2luZxgDIAEoCEID4EEBIlAKEURlbGV0ZVVzZXJSZXF1ZXN0EicKBG5hbWUYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL1VzZXISEgoFZm9yY2UYAiABKAhCA+BBASLYAwoJVXNlclN0YXRzEhEKBG5hbWUYASABKAlCA+BBCBI7ChdtZW1vX2Rpc3BsYXlfdGltZXN0YW1wcxgCIAMoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASPgoPbWVtb190eXBlX3N0YXRzGAMgASgLMiUubWVtb3MuYXBpLnYxLlVzZXJTdGF0cy5NZW1vVHlwZVN0YXRzEjgKCXRhZ19jb3VudBgEIAMoCzIlLm1lbW9zLmFwaS52MS5Vc2VyU3RhdHMuVGFnQ291bnRFbnRyeRIUCgxwaW5uZWRfbWVtb3MYBSADKAkSGAoQdG90YWxfbWVtb19jb3VudBgGIAEoBRovCg1UYWdDb3VudEVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoBToCOAEaXwoNTWVtb1R5cGVTdGF0cxISCgpsaW5rX2NvdW50GAEgASgFEhIKCmNvZGVfY291bnQYAiABKAUSEgoKdG9kb19jb3VudBgDIAEoBRISCgp1bmRvX2NvdW50GAQgASgFOj/qQTwKFm1lbW9zLmFwaS52MS9Vc2VyU3RhdHMSDHVzZXJzL3t1c2VyfSoJdXNlclN0YXRzMgl1c2VyU3RhdHMiPgoTR2V0VXNlclN0YXRzUmVxdWVzdBInCgRuYW1lGAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyIhkKF0xpc3RBbGxVc2VyU3RhdHNSZXF1ZXN0IkIKGExpc3RBbGxVc2VyU3RhdHNSZXNwb25zZRImCgVzdGF0cxgBIAMoCzIXLm1lbW9zLmFwaS52MS5Vc2VyU3RhdHMi4AMKC1VzZXJTZXR0aW5nEhEKBG5hbWUYASABKAlCA+BBCBJDCg9nZW5lcmFsX3NldHRpbmcYAiABKAsyKC5tZW1vcy5hcGkudjEuVXNlclNldHRpbmcuR2VuZXJhbFNldHRpbmdIABJFChB3ZWJob29rc19zZXR0aW5nGAUgASgLMikubWVtb3MuYXBpLnYxLlVzZXJTZXR0aW5nLldlYmhvb2tzU2V0dGluZ0gAGlcKDkdlbmVyYWxTZXR0aW5nEhMKBmxvY2FsZRgBIAEoCUID4EEBEhwKD21lbW9fdmlzaWJpbGl0eRgDIAEoCUID4EEBEhIKBXRoZW1lGAQgASgJQgPgQQEaPgoPV2ViaG9va3NTZXR0aW5nEisKCHdlYmhvb2tzGAEgAygLMhkubWVtb3MuYXBpLnYxLlVzZXJXZWJob29rIjUKA0tleRITCg9LRVlfVU5TUEVDSUZJRUQQABILCgdHRU5FUkFMEAESDAoIV0VCSE9PS1MQBDpZ6kFWChhtZW1vcy5hcGkudjEvVXNlclNldHRpbmcSH3VzZXJzL3t1c2VyfS9zZXR0aW5ncy97c2V0dGluZ30qDHVzZXJTZXR0aW5nczILdXNlclNldHRpbmdCBwoFdmFsdWUiRwoVR2V0VXNlclNldHRpbmdSZXF1ZXN0Ei4KBG5hbWUYASABKAlCIOBBAvpBGgoYbWVtb3MuYXBpLnYxL1VzZXJTZXR0aW5nIoEBChhVcGRhdGVVc2VyU2V0dGluZ1JlcXVlc3QSLwoHc2V0dGluZxgBIAEoCzIZLm1lbW9zLmFwaS52MS5Vc2VyU2V0dGluZ0ID4EECEjQKC3VwZGF0ZV9tYXNrGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLkZpZWxkTWFza0ID4EECInUKF0xpc3RVc2VyU2V0dGluZ3NSZXF1ZXN0EikKBnBhcmVudBgBIAEoCUIZ4EEC+kETChFtZW1vcy5hcGkudjEvVXNlchIWCglwYWdlX3NpemUYAiABKAVCA+BBARIXCgpwYWdlX3Rva2VuGAMgASgJQgPgQQEidAoYTGlzdFVzZXJTZXR0aW5nc1Jlc3BvbnNlEisKCHNldHRpbmdzGAEgAygLMhkubWVtb3MuYXBpLnYxLlVzZXJTZXR0aW5nEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCRISCgp0b3RhbF9zaXplGAMgASgFIvICChNQZXJzb25hbEFjY2Vzc1Rva2VuEhEKBG5hbWUYASABKAlCA+BBCBIYCgtkZXNjcmlwdGlvbhgCIAEoCUID4EEBEjMKCmNyZWF0ZWRfYXQYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgPgQQMSMwoKZXhwaXJlc19hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBARI1CgxsYXN0X3VzZWRfYXQYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgPgQQM6jAHqQYgBCiBtZW1vcy5hcGkudjEvUGVyc29uYWxBY2Nlc3NUb2tlbhI5dXNlcnMve3VzZXJ9L3BlcnNvbmFsQWNjZXNzVG9rZW5zL3twZXJzb25hbF9hY2Nlc3NfdG9rZW59KhRwZXJzb25hbEFjY2Vzc1Rva2VuczITcGVyc29uYWxBY2Nlc3NUb2tlbiJ9Ch9MaXN0UGVyc29uYWxBY2Nlc3NUb2tlbnNSZXF1ZXN0EikKBnBhcmVudBgBIAEoCUIZ4EEC+kETChFtZW1vcy5hcGkudjEvVXNlchIWCglwYWdlX3NpemUYAiABKAVCA+BBARIXCgpwYWdlX3Rva2VuGAMgASgJQgPgQQEikgEKIExpc3RQZXJzb25hbEFjY2Vzc1Rva2Vuc1Jlc3BvbnNlEkEKFnBlcnNvbmFsX2FjY2Vzc190b2tlbnMYASADKAsyIS5tZW1vcy5hcGkudjEuUGVyc29uYWxBY2Nlc3NUb2tlbhIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkSEgoKdG90YWxfc2l6ZRgDIAEoBSKFAQogQ3JlYXRlUGVyc29uYWxBY2Nlc3NUb2tlblJlcXVlc3QSKQoGcGFyZW50GAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyEhgKC2Rlc2NyaXB0aW9uGAIgASgJQgPgQQESHAoPZXhwaXJlc19pbl9kYXlzGAMgASgFQgPgQQEidAohQ3JlYXRlUGVyc29uYWxBY2Nlc3NUb2tlblJlc3BvbnNlEkAKFXBlcnNvbmFsX2FjY2Vzc190b2tlbhgBIAEoCzIhLm1lbW9zLmFwaS52MS5QZXJzb25hbEFjY2Vzc1Rva2VuEg0KBXRva2VuGAIgASgJIloKIERlbGV0ZVBlcnNvbmFsQWNjZXNzVG9rZW5SZXF1ZXN0EjYKBG5hbWUYASABKAlCKOBBAvpBIgogbWVtb3MuYXBpLnYxL1BlcnNvbmFsQWNjZXNzVG9rZW4iqgEKC1VzZXJXZWJob29rEgwKBG5hbWUYASABKAkSCwoDdXJsGAIgASgJEhQKDGRpc3BsYXlfbmFtZRgDIAEoCRI0CgtjcmVhdGVfdGltZRgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAxI0Cgt1cGRhdGVfdGltZRgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAyIuChdMaXN0VXNlcldlYmhvb2tzUmVxdWVzdBITCgZwYXJlbnQYASABKAlCA+BBAiJHChhMaXN0VXNlcldlYmhvb2tzUmVzcG9uc2USKwoId2ViaG9va3MYASADKAsyGS5tZW1vcy5hcGkudjEuVXNlcldlYmhvb2siYAoYQ3JlYXRlVXNlcldlYmhvb2tSZXF1ZXN0EhMKBnBhcmVudBgBIAEoCUID4EECEi8KB3dlYmhvb2sYAiABKAsyGS5tZW1vcy5hcGkudjEuVXNlcldlYmhvb2tCA+BBAiJ8ChhVcGRhdGVVc2VyV2ViaG9va1JlcXVlc3QSLwoHd2ViaG9vaxgBIAEoCzIZLm1lbW9zLmFwaS52MS5Vc2VyV2ViaG9va0ID4EECEi8KC3VwZGF0ZV9tYXNrGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLkZpZWxkTWFzayItChhEZWxldGVVc2VyV2ViaG9va1JlcXVlc3QSEQoEbmFtZRgBIAEoCUID4EECIvAEChBVc2VyTm90aWZpY2F0aW9uEhQKBG5hbWUYASABKAlCBuBBA+BBCBIpCgZzZW5kZXIYAiABKAlCGeBBA/pBEwoRbWVtb3MuYXBpLnYxL1VzZXISOgoGc3RhdHVzGAMgASgOMiUubWVtb3MuYXBpLnYxLlVzZXJOb3RpZmljYXRpb24uU3RhdHVzQgPgQQESNAoLY3JlYXRlX3RpbWUYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgPgQQMSNgoEdHlwZRgFIAEoDjIjLm1lbW9zLmFwaS52MS5Vc2VyTm90aWZpY2F0aW9uLlR5cGVCA+BBAxJOCgxtZW1vX2NvbW1lbnQYBiABKAsyMS5tZW1vcy5hcGkudjEuVXNlck5vdGlmaWNhdGlvbi5NZW1vQ29tbWVudFBheWxvYWRCA+BBA0gAGjgKEk1lbW9Db21tZW50UGF5bG9hZBIMCgRtZW1vGAEgASgJEhQKDHJlbGF0ZWRfbWVtbxgCIAEoCSI6CgZTdGF0dXMSFgoSU1RBVFVTX1VOU1BFQ0lGSUVEEAASCgoGVU5SRUFEEAESDAoIQVJDSElWRUQQAiIuCgRUeXBlEhQKEFRZUEVfVU5TUEVDSUZJRUQQABIQCgxNRU1PX0NPTU1FTlQQATpw6kFtCh1tZW1vcy5hcGkudjEvVXNlck5vdGlmaWNhdGlvbhIpdXNlcnMve3VzZXJ9L25vdGlmaWNhdGlvbnMve25vdGlmaWNhdGlvbn0aBG5hbWUqDW5vdGlmaWNhdGlvbnMyDG5vdGlmaWNhdGlvbkIJCgdwYXlsb2FkIo8BChxMaXN0VXNlck5vdGlmaWNhdGlvbnNSZXF1ZXN0EikKBnBhcmVudBgBIAEoCUIZ4EEC+kETChFtZW1vcy5hcGkudjEvVXNlchIWCglwYWdlX3NpemUYAiABKAVCA+BBARIXCgpwYWdlX3Rva2VuGAMgASgJQgPgQQESEwoGZmlsdGVyGAQgASgJQgPgQQEibwodTGlzdFVzZXJOb3RpZmljYXRpb25zUmVzcG9uc2USNQoNbm90aWZpY2F0aW9ucxgBIAMoCzIeLm1lbW9zLmFwaS52MS5Vc2VyTm90aWZpY2F0aW9uEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCSKQAQodVXBkYXRlVXNlck5vdGlmaWNhdGlvblJlcXVlc3QSOQoMbm90aWZpY2F0aW9uGAEgASgLMh4ubWVtb3MuYXBpLnYxLlVzZXJOb3RpZmljYXRpb25CA+BBAhI0Cgt1cGRhdGVfbWFzaxgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE1hc2tCA+BBAiJUCh1EZWxldGVVc2VyTm90aWZpY2F0aW9uUmVxdWVzdBIzCgRuYW1lGAEgASgJQiXgQQL6QR8KHW1lbW9zLmFwaS52MS9Vc2VyTm90aWZpY2F0aW9uMoMXCgtVc2VyU2VydmljZRJjCglMaXN0VXNlcnMSHi5tZW1vcy5hcGkudjEuTGlzdFVzZXJzUmVxdWVzdBofLm1lbW9zLmFwaS52MS5MaXN0VXNlcnNSZXNwb25zZSIVgtPkkwIPEg0vYXBpL3YxL3VzZXJzEmIKB0dldFVzZXISHC5tZW1vcy5hcGkudjEuR2V0VXNlclJlcXVlc3QaEi5tZW1vcy5hcGkudjEuVXNlciIl2kEEbmFtZYLT5JMCGBIWL2FwaS92MS97bmFtZT11c2Vycy8qfRJlCgpDcmVhdGVVc2VyEh8ubWVtb3MuYXBpLnYxLkNyZWF0ZVVzZXJSZXF1ZXN0GhIubWVtb3MuYXBpLnYxLlVzZXIiItpBBHVzZXKC0+STAhU6BHVzZXIiDS9hcGkvdjEvdXNlcnMSfwoKVXBkYXRlVXNlchIfLm1lbW9zLmFwaS52MS5VcGRhdGVVc2VyUmVxdWVzdBoSLm1lbW9zLmFwaS52MS5Vc2VyIjzaQRB1c2VyLHVwZGF0ZV9tYXNrgtPkkwIjOgR1c2VyMhsvYXBpL3YxL3t1c2VyLm5hbWU9dXNlcnMvKn0SbAoKRGVsZXRlVXNlchIfLm1lbW9zLmFwaS52MS5EZWxldGVVc2VyUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIl2kEEbmFtZYLT5JMCGCoWL2FwaS92MS97bmFtZT11c2Vycy8qfRJ+ChBMaXN0QWxsVXNlclN0YXRzEiUubWVtb3MuYXBpLnYxLkxpc3RBbGxVc2VyU3RhdHNSZXF1ZXN0GiYubWVtb3MuYXBpLnYxLkxpc3RBbGxVc2VyU3RhdHNSZXNwb25zZSIbgtPkkwIVEhMvYXBpL3YxL3VzZXJzOnN0YXRzEnoKDEdldFVzZXJTdGF0cxIhLm1lbW9zLmFwaS52MS5HZXRVc2VyU3RhdHNSZXF1ZXN0GhcubWVtb3MuYXBpLnYxLlVzZXJTdGF0cyIu2kEEbmFtZYLT5JMCIRIfL2FwaS92MS97bmFtZT11c2Vycy8qfTpnZXRTdGF0cxKCAQoOR2V0VXNlclNldHRpbmcSIy5tZW1vcy5hcGkudjEuR2V0VXNlclNldHRpbmdSZXF1ZXN0GhkubWVtb3MuYXBpLnYxLlVzZXJTZXR0aW5nIjDaQQRuYW1lgtPkkwIjEiEvYXBpL3YxL3tuYW1lPXVzZXJzLyovc2V0dGluZ3MvKn0SqAEKEVVwZGF0ZVVzZXJTZXR0aW5nEiYubWVtb3MuYXBpLnYxLlVwZGF0ZVVzZXJTZXR0aW5nUmVxdWVzdBoZLm1lbW9zLmFwaS52MS5Vc2VyU2V0dGluZyJQ2kETc2V0dGluZyx1cGRhdGVfbWFza4LT5JMCNDoHc2V0dGluZzIpL2FwaS92MS97c2V0dGluZy5uYW1lPXVzZXJzLyovc2V0dGluZ3MvKn0SlQEKEExpc3RVc2VyU2V0dGluZ3MSJS5tZW1vcy5hcGkudjEuTGlzdFVzZXJTZXR0aW5nc1JlcXVlc3QaJi5tZW1vcy5hcGkudjEuTGlzdFVzZXJTZXR0aW5nc1Jlc3BvbnNlIjLaQQZwYXJlbnSC0+STAiMSIS9hcGkvdjEve3BhcmVudD11c2Vycy8qfS9zZXR0aW5ncxK5AQoYTGlzdFBlcnNvbmFsQWNjZXNzVG9rZW5zEi0ubWVtb3MuYXBpLnYxLkxpc3RQZXJzb25hbEFjY2Vzc1Rva2Vuc1JlcXVlc3QaLi5tZW1vcy5hcGkudjEuTGlzdFBlcnNvbmFsQWNjZXNzVG9rZW5zUmVzcG9uc2UiPtpBBnBhcmVudILT5JMCLxItL2FwaS92MS97cGFyZW50PXVzZXJzLyp9L3BlcnNvbmFsQWNjZXNzVG9rZW5zErYBChlDcmVhdGVQZXJzb25hbEFjY2Vzc1Rva2VuEi4ubWVtb3MuYXBpLnYxLkNyZWF0ZVBlcnNvbmFsQWNjZXNzVG9rZW5SZXF1ZXN0Gi8ubWVtb3MuYXBpLnYxLkNyZWF0ZVBlcnNvbmFsQWNjZXNzVG9rZW5SZXNwb25zZSI4gtPkkwIyOgEqIi0vYXBpL3YxL3twYXJlbnQ9dXNlcnMvKn0vcGVyc29uYWxBY2Nlc3NUb2tlbnMSoQEKGURlbGV0ZVBlcnNvbmFsQWNjZXNzVG9rZW4SLi5tZW1vcy5hcGkudjEuRGVsZXRlUGVyc29uYWxBY2Nlc3NUb2tlblJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiPNpBBG5hbWWC0+STAi8qLS9hcGkvdjEve25hbWU9dXNlcnMvKi9wZXJzb25hbEFjY2Vzc1Rva2Vucy8qfRKVAQoQTGlzdFVzZXJXZWJob29rcxIlLm1lbW9zLmFwaS52MS5MaXN0VXNlcldlYmhvb2tzUmVxdWVzdBomLm1lbW9zLmFwaS52MS5MaXN0VXNlcldlYmhvb2tzUmVzcG9uc2UiMtpBBnBhcmVudILT5JMCIxIhL2FwaS92MS97cGFyZW50PXVzZXJzLyp9L3dlYmhvb2tzEpsBChFDcmVhdGVVc2VyV2ViaG9vaxImLm1lbW9zLmFwaS52MS5DcmVhdGVVc2VyV2ViaG9va1JlcXVlc3QaGS5tZW1vcy5hcGkudjEuVXNlcldlYmhvb2siQ9pBDnBhcmVudCx3ZWJob29rgtPkkwIsOgd3ZWJob29rIiEvYXBpL3YxL3twYXJlbnQ9dXNlcnMvKn0vd2ViaG9va3MSqAEKEVVwZGF0ZVVzZXJXZWJob29rEiYubWVtb3MuYXBpLnYxLlVwZGF0ZVVzZXJXZWJob29rUmVxdWVzdBoZLm1lbW9zLmFwaS52MS5Vc2VyV2ViaG9vayJQ2kETd2ViaG9vayx1cGRhdGVfbWFza4LT5JMCNDoHd2ViaG9vazIpL2FwaS92MS97d2ViaG9vay5uYW1lPXVzZXJzLyovd2ViaG9va3MvKn0ShQEKEURlbGV0ZVVzZXJXZWJob29rEiYubWVtb3MuYXBpLnYxLkRlbGV0ZVVzZXJXZWJob29rUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIw2kEEbmFtZYLT5JMCIyohL2FwaS92MS97bmFtZT11c2Vycy8qL3dlYmhvb2tzLyp9EqkBChVMaXN0VXNlck5vdGlmaWNhdGlvbnMSKi5tZW1vcy5hcGkudjEuTGlzdFVzZXJOb3RpZmljYXRpb25zUmVxdWVzdBorLm1lbW9zLmFwaS52MS5MaXN0VXNlck5vdGlmaWNhdGlvbnNSZXNwb25zZSI32kEGcGFyZW50gtPkkwIoEiYvYXBpL3YxL3twYXJlbnQ9dXNlcnMvKn0vbm90aWZpY2F0aW9ucxLLAQoWVXBkYXRlVXNlck5vdGlmaWNhdGlvbhIrLm1lbW9zLmFwaS52MS5VcGRhdGVVc2VyTm90aWZpY2F0aW9uUmVxdWVzdBoeLm1lbW9zLmFwaS52MS5Vc2VyTm90aWZpY2F0aW9uImTaQRhub3RpZmljYXRpb24sdXBkYXRlX21hc2uC0+STAkM6DG5vdGlmaWNhdGlvbjIzL2FwaS92MS97bm90aWZpY2F0aW9uLm5hbWU9dXNlcnMvKi9ub3RpZmljYXRpb25zLyp9EpQBChZEZWxldGVVc2VyTm90aWZpY2F0aW9uEisubWVtb3MuYXBpLnYxLkRlbGV0ZVVzZXJOb3RpZmljYXRpb25SZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IjXaQQRuYW1lgtPkkwIoKiYvYXBpL3YxL3tuYW1lPXVzZXJzLyovbm90aWZpY2F0aW9ucy8qfUKoAQoQY29tLm1lbW9zLmFwaS52MUIQVXNlclNlcnZpY2VQcm90b1ABWjBnaXRodWIuY29tL3VzZW1lbW9zL21lbW9zL3Byb3RvL2dlbi9hcGkvdjE7YXBpdjGiAgNNQViqAgxNZW1vcy5BcGkuVjHKAgxNZW1vc1xBcGlcVjHiAhhNZW1vc1xBcGlcVjFcR1BCTWV0YWRhdGHqAg5NZW1vczo6QXBpOjpWMWIGcHJvdG8z", [file_api_v1_common, file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_empty, file_google_protobuf_field_mask, file_google_protobuf_timestamp]); fileDesc("ChlhcGkvdjEvdXNlcl9zZXJ2aWNlLnByb3RvEgxtZW1vcy5hcGkudjEi1gMKBFVzZXISEQoEbmFtZRgBIAEoCUID4EEIEioKBHJvbGUYAiABKA4yFy5tZW1vcy5hcGkudjEuVXNlci5Sb2xlQgPgQQISFQoIdXNlcm5hbWUYAyABKAlCA+BBAhISCgVlbWFpbBgEIAEoCUID4EEBEhkKDGRpc3BsYXlfbmFtZRgFIAEoCUID4EEBEhcKCmF2YXRhcl91cmwYBiABKAlCA+BBARIYCgtkZXNjcmlwdGlvbhgHIAEoCUID4EEBEhUKCHBhc3N3b3JkGAggASgJQgPgQQQSJwoFc3RhdGUYCSABKA4yEy5tZW1vcy5hcGkudjEuU3RhdGVCA+BBAhI0CgtjcmVhdGVfdGltZRgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAxI0Cgt1cGRhdGVfdGltZRgLIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAyIxCgRSb2xlEhQKEFJPTEVfVU5TUEVDSUZJRUQQABIJCgVBRE1JThACEggKBFVTRVIQAzo36kE0ChFtZW1vcy5hcGkudjEvVXNlchIMdXNlcnMve3VzZXJ9GgRuYW1lKgV1c2VyczIEdXNlciJzChBMaXN0VXNlcnNSZXF1ZXN0EhYKCXBhZ2Vfc2l6ZRgBIAEoBUID4EEBEhcKCnBhZ2VfdG9rZW4YAiABKAlCA+BBARITCgZmaWx0ZXIYAyABKAlCA+BBARIZCgxzaG93X2RlbGV0ZWQYBCABKAhCA+BBASJjChFMaXN0VXNlcnNSZXNwb25zZRIhCgV1c2VycxgBIAMoCzISLm1lbW9zLmFwaS52MS5Vc2VyEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCRISCgp0b3RhbF9zaXplGAMgASgFIm0KDkdldFVzZXJSZXF1ZXN0EicKBG5hbWUYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL1VzZXISMgoJcmVhZF9tYXNrGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLkZpZWxkTWFza0ID4EEBIogBChFDcmVhdGVVc2VyUmVxdWVzdBIoCgR1c2VyGAEgASgLMhIubWVtb3MuYXBpLnYxLlVzZXJCBuBBAuBBBBIUCgd1c2VyX2lkGAIgASgJQgPgQQESGgoNdmFsaWRhdGVfb25seRgDIAEoCEID4EEBEhcKCnJlcXVlc3RfaWQYBCABKAlCA+BBASKMAQoRVXBkYXRlVXNlclJlcXVlc3QSJQoEdXNlchgBIAEoCzISLm1lbW9zLmFwaS52MS5Vc2VyQgPgQQISNAoLdXBkYXRlX21hc2sYAiABKAsyGi5nb29nbGUucHJvdG9idWYuRmllbGRNYXNrQgPgQQISGgoNYWxsb3dfbWlzc2luZxgDIAEoCEID4EEBIlAKEURlbGV0ZVVzZXJSZXF1ZXN0EicKBG5hbWUYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL1VzZXISEgoFZm9yY2UYAiABKAhCA+BBASLYAwoJVXNlclN0YXRzEhEKBG5hbWUYASABKAlCA+BBCBI7ChdtZW1vX2Rpc3BsYXlfdGltZXN0YW1wcxgCIAMoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASPgoPbWVtb190eXBlX3N0YXRzGAMgASgLMiUubWVtb3MuYXBpLnYxLlVzZXJTdGF0cy5NZW1vVHlwZVN0YXRzEjgKCXRhZ19jb3VudBgEIAMoCzIlLm1lbW9zLmFwaS52MS5Vc2VyU3RhdHMuVGFnQ291bnRFbnRyeRIUCgxwaW5uZWRfbWVtb3MYBSADKAkSGAoQdG90YWxfbWVtb19jb3VudBgGIAEoBRovCg1UYWdDb3VudEVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoBToCOAEaXwoNTWVtb1R5cGVTdGF0cxISCgpsaW5rX2NvdW50GAEgASgFEhIKCmNvZGVfY291bnQYAiABKAUSEgoKdG9kb19jb3VudBgDIAEoBRISCgp1bmRvX2NvdW50GAQgASgFOj/qQTwKFm1lbW9zLmFwaS52MS9Vc2VyU3RhdHMSDHVzZXJzL3t1c2VyfSoJdXNlclN0YXRzMgl1c2VyU3RhdHMiPgoTR2V0VXNlclN0YXRzUmVxdWVzdBInCgRuYW1lGAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyIhkKF0xpc3RBbGxVc2VyU3RhdHNSZXF1ZXN0IkIKGExpc3RBbGxVc2VyU3RhdHNSZXNwb25zZRImCgVzdGF0cxgBIAMoCzIXLm1lbW9zLmFwaS52MS5Vc2VyU3RhdHMi5AMKC1VzZXJTZXR0aW5nEhEKBG5hbWUYASABKAlCA+BBCBJDCg9nZW5lcmFsX3NldHRpbmcYAiABKAsyKC5tZW1vcy5hcGkudjEuVXNlclNldHRpbmcuR2VuZXJhbFNldHRpbmdIABJFChB3ZWJob29rc19zZXR0aW5nGAUgASgLMikubWVtb3MuYXBpLnYxLlVzZXJTZXR0aW5nLldlYmhvb2tzU2V0dGluZ0gAGlcKDkdlbmVyYWxTZXR0aW5nEhMKBmxvY2FsZRgBIAEoCUID4EEBEhwKD21lbW9fdmlzaWJpbGl0eRgDIAEoCUID4EEBEhIKBXRoZW1lGAQgASgJQgPgQQEaPgoPV2ViaG9va3NTZXR0aW5nEisKCHdlYmhvb2tzGAEgAygLMhkubWVtb3MuYXBpLnYxLlVzZXJXZWJob29rIjUKA0tleRITCg9LRVlfVU5TUEVDSUZJRUQQABILCgdHRU5FUkFMEAESDAoIV0VCSE9PS1MQBDpd6kFaChhtZW1vcy5hcGkudjEvVXNlclNldHRpbmcSI3VzZXJzL3t1c2VybmFtZX0vc2V0dGluZ3Mve3NldHRpbmd9Kgx1c2VyU2V0dGluZ3MyC3VzZXJTZXR0aW5nQgcKBXZhbHVlIkcKFUdldFVzZXJTZXR0aW5nUmVxdWVzdBIuCgRuYW1lGAEgASgJQiDgQQL6QRoKGG1lbW9zLmFwaS52MS9Vc2VyU2V0dGluZyKBAQoYVXBkYXRlVXNlclNldHRpbmdSZXF1ZXN0Ei8KB3NldHRpbmcYASABKAsyGS5tZW1vcy5hcGkudjEuVXNlclNldHRpbmdCA+BBAhI0Cgt1cGRhdGVfbWFzaxgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE1hc2tCA+BBAiJ1ChdMaXN0VXNlclNldHRpbmdzUmVxdWVzdBIpCgZwYXJlbnQYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL1VzZXISFgoJcGFnZV9zaXplGAIgASgFQgPgQQESFwoKcGFnZV90b2tlbhgDIAEoCUID4EEBInQKGExpc3RVc2VyU2V0dGluZ3NSZXNwb25zZRIrCghzZXR0aW5ncxgBIAMoCzIZLm1lbW9zLmFwaS52MS5Vc2VyU2V0dGluZxIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkSEgoKdG90YWxfc2l6ZRgDIAEoBSLyAgoTUGVyc29uYWxBY2Nlc3NUb2tlbhIRCgRuYW1lGAEgASgJQgPgQQgSGAoLZGVzY3JpcHRpb24YAiABKAlCA+BBARIzCgpjcmVhdGVkX2F0GAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEID4EEDEjMKCmV4cGlyZXNfYXQYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgPgQQESNQoMbGFzdF91c2VkX2F0GAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEID4EEDOowB6kGIAQogbWVtb3MuYXBpLnYxL1BlcnNvbmFsQWNjZXNzVG9rZW4SOXVzZXJzL3t1c2VyfS9wZXJzb25hbEFjY2Vzc1Rva2Vucy97cGVyc29uYWxfYWNjZXNzX3Rva2VufSoUcGVyc29uYWxBY2Nlc3NUb2tlbnMyE3BlcnNvbmFsQWNjZXNzVG9rZW4ifQofTGlzdFBlcnNvbmFsQWNjZXNzVG9rZW5zUmVxdWVzdBIpCgZwYXJlbnQYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL1VzZXISFgoJcGFnZV9zaXplGAIgASgFQgPgQQESFwoKcGFnZV90b2tlbhgDIAEoCUID4EEBIpIBCiBMaXN0UGVyc29uYWxBY2Nlc3NUb2tlbnNSZXNwb25zZRJBChZwZXJzb25hbF9hY2Nlc3NfdG9rZW5zGAEgAygLMiEubWVtb3MuYXBpLnYxLlBlcnNvbmFsQWNjZXNzVG9rZW4SFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJEhIKCnRvdGFsX3NpemUYAyABKAUihQEKIENyZWF0ZVBlcnNvbmFsQWNjZXNzVG9rZW5SZXF1ZXN0EikKBnBhcmVudBgBIAEoCUIZ4EEC+kETChFtZW1vcy5hcGkudjEvVXNlchIYCgtkZXNjcmlwdGlvbhgCIAEoCUID4EEBEhwKD2V4cGlyZXNfaW5fZGF5cxgDIAEoBUID4EEBInQKIUNyZWF0ZVBlcnNvbmFsQWNjZXNzVG9rZW5SZXNwb25zZRJAChVwZXJzb25hbF9hY2Nlc3NfdG9rZW4YASABKAsyIS5tZW1vcy5hcGkudjEuUGVyc29uYWxBY2Nlc3NUb2tlbhINCgV0b2tlbhgCIAEoCSJaCiBEZWxldGVQZXJzb25hbEFjY2Vzc1Rva2VuUmVxdWVzdBI2CgRuYW1lGAEgASgJQijgQQL6QSIKIG1lbW9zLmFwaS52MS9QZXJzb25hbEFjY2Vzc1Rva2VuIqoBCgtVc2VyV2ViaG9vaxIMCgRuYW1lGAEgASgJEgsKA3VybBgCIAEoCRIUCgxkaXNwbGF5X25hbWUYAyABKAkSNAoLY3JlYXRlX3RpbWUYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgPgQQMSNAoLdXBkYXRlX3RpbWUYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgPgQQMiLgoXTGlzdFVzZXJXZWJob29rc1JlcXVlc3QSEwoGcGFyZW50GAEgASgJQgPgQQIiRwoYTGlzdFVzZXJXZWJob29rc1Jlc3BvbnNlEisKCHdlYmhvb2tzGAEgAygLMhkubWVtb3MuYXBpLnYxLlVzZXJXZWJob29rImAKGENyZWF0ZVVzZXJXZWJob29rUmVxdWVzdBITCgZwYXJlbnQYASABKAlCA+BBAhIvCgd3ZWJob29rGAIgASgLMhkubWVtb3MuYXBpLnYxLlVzZXJXZWJob29rQgPgQQIifAoYVXBkYXRlVXNlcldlYmhvb2tSZXF1ZXN0Ei8KB3dlYmhvb2sYASABKAsyGS5tZW1vcy5hcGkudjEuVXNlcldlYmhvb2tCA+BBAhIvCgt1cGRhdGVfbWFzaxgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE1hc2siLQoYRGVsZXRlVXNlcldlYmhvb2tSZXF1ZXN0EhEKBG5hbWUYASABKAlCA+BBAiLwBAoQVXNlck5vdGlmaWNhdGlvbhIUCgRuYW1lGAEgASgJQgbgQQPgQQgSKQoGc2VuZGVyGAIgASgJQhngQQP6QRMKEW1lbW9zLmFwaS52MS9Vc2VyEjoKBnN0YXR1cxgDIAEoDjIlLm1lbW9zLmFwaS52MS5Vc2VyTm90aWZpY2F0aW9uLlN0YXR1c0ID4EEBEjQKC2NyZWF0ZV90aW1lGAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEID4EEDEjYKBHR5cGUYBSABKA4yIy5tZW1vcy5hcGkudjEuVXNlck5vdGlmaWNhdGlvbi5UeXBlQgPgQQMSTgoMbWVtb19jb21tZW50GAYgASgLMjEubWVtb3MuYXBpLnYxLlVzZXJOb3RpZmljYXRpb24uTWVtb0NvbW1lbnRQYXlsb2FkQgPgQQNIABo4ChJNZW1vQ29tbWVudFBheWxvYWQSDAoEbWVtbxgBIAEoCRIUCgxyZWxhdGVkX21lbW8YAiABKAkiOgoGU3RhdHVzEhYKElNUQVRVU19VTlNQRUNJRklFRBAAEgoKBlVOUkVBRBABEgwKCEFSQ0hJVkVEEAIiLgoEVHlwZRIUChBUWVBFX1VOU1BFQ0lGSUVEEAASEAoMTUVNT19DT01NRU5UEAE6cOpBbQodbWVtb3MuYXBpLnYxL1VzZXJOb3RpZmljYXRpb24SKXVzZXJzL3t1c2VyfS9ub3RpZmljYXRpb25zL3tub3RpZmljYXRpb259GgRuYW1lKg1ub3RpZmljYXRpb25zMgxub3RpZmljYXRpb25CCQoHcGF5bG9hZCKPAQocTGlzdFVzZXJOb3RpZmljYXRpb25zUmVxdWVzdBIpCgZwYXJlbnQYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL1VzZXISFgoJcGFnZV9zaXplGAIgASgFQgPgQQESFwoKcGFnZV90b2tlbhgDIAEoCUID4EEBEhMKBmZpbHRlchgEIAEoCUID4EEBIm8KHUxpc3RVc2VyTm90aWZpY2F0aW9uc1Jlc3BvbnNlEjUKDW5vdGlmaWNhdGlvbnMYASADKAsyHi5tZW1vcy5hcGkudjEuVXNlck5vdGlmaWNhdGlvbhIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkikAEKHVVwZGF0ZVVzZXJOb3RpZmljYXRpb25SZXF1ZXN0EjkKDG5vdGlmaWNhdGlvbhgBIAEoCzIeLm1lbW9zLmFwaS52MS5Vc2VyTm90aWZpY2F0aW9uQgPgQQISNAoLdXBkYXRlX21hc2sYAiABKAsyGi5nb29nbGUucHJvdG9idWYuRmllbGRNYXNrQgPgQQIiVAodRGVsZXRlVXNlck5vdGlmaWNhdGlvblJlcXVlc3QSMwoEbmFtZRgBIAEoCUIl4EEC+kEfCh1tZW1vcy5hcGkudjEvVXNlck5vdGlmaWNhdGlvbjKDFwoLVXNlclNlcnZpY2USYwoJTGlzdFVzZXJzEh4ubWVtb3MuYXBpLnYxLkxpc3RVc2Vyc1JlcXVlc3QaHy5tZW1vcy5hcGkudjEuTGlzdFVzZXJzUmVzcG9uc2UiFYLT5JMCDxINL2FwaS92MS91c2VycxJiCgdHZXRVc2VyEhwubWVtb3MuYXBpLnYxLkdldFVzZXJSZXF1ZXN0GhIubWVtb3MuYXBpLnYxLlVzZXIiJdpBBG5hbWWC0+STAhgSFi9hcGkvdjEve25hbWU9dXNlcnMvKn0SZQoKQ3JlYXRlVXNlchIfLm1lbW9zLmFwaS52MS5DcmVhdGVVc2VyUmVxdWVzdBoSLm1lbW9zLmFwaS52MS5Vc2VyIiLaQQR1c2VygtPkkwIVOgR1c2VyIg0vYXBpL3YxL3VzZXJzEn8KClVwZGF0ZVVzZXISHy5tZW1vcy5hcGkudjEuVXBkYXRlVXNlclJlcXVlc3QaEi5tZW1vcy5hcGkudjEuVXNlciI82kEQdXNlcix1cGRhdGVfbWFza4LT5JMCIzoEdXNlcjIbL2FwaS92MS97dXNlci5uYW1lPXVzZXJzLyp9EmwKCkRlbGV0ZVVzZXISHy5tZW1vcy5hcGkudjEuRGVsZXRlVXNlclJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiJdpBBG5hbWWC0+STAhgqFi9hcGkvdjEve25hbWU9dXNlcnMvKn0SfgoQTGlzdEFsbFVzZXJTdGF0cxIlLm1lbW9zLmFwaS52MS5MaXN0QWxsVXNlclN0YXRzUmVxdWVzdBomLm1lbW9zLmFwaS52MS5MaXN0QWxsVXNlclN0YXRzUmVzcG9uc2UiG4LT5JMCFRITL2FwaS92MS91c2VyczpzdGF0cxJ6CgxHZXRVc2VyU3RhdHMSIS5tZW1vcy5hcGkudjEuR2V0VXNlclN0YXRzUmVxdWVzdBoXLm1lbW9zLmFwaS52MS5Vc2VyU3RhdHMiLtpBBG5hbWWC0+STAiESHy9hcGkvdjEve25hbWU9dXNlcnMvKn06Z2V0U3RhdHMSggEKDkdldFVzZXJTZXR0aW5nEiMubWVtb3MuYXBpLnYxLkdldFVzZXJTZXR0aW5nUmVxdWVzdBoZLm1lbW9zLmFwaS52MS5Vc2VyU2V0dGluZyIw2kEEbmFtZYLT5JMCIxIhL2FwaS92MS97bmFtZT11c2Vycy8qL3NldHRpbmdzLyp9EqgBChFVcGRhdGVVc2VyU2V0dGluZxImLm1lbW9zLmFwaS52MS5VcGRhdGVVc2VyU2V0dGluZ1JlcXVlc3QaGS5tZW1vcy5hcGkudjEuVXNlclNldHRpbmciUNpBE3NldHRpbmcsdXBkYXRlX21hc2uC0+STAjQ6B3NldHRpbmcyKS9hcGkvdjEve3NldHRpbmcubmFtZT11c2Vycy8qL3NldHRpbmdzLyp9EpUBChBMaXN0VXNlclNldHRpbmdzEiUubWVtb3MuYXBpLnYxLkxpc3RVc2VyU2V0dGluZ3NSZXF1ZXN0GiYubWVtb3MuYXBpLnYxLkxpc3RVc2VyU2V0dGluZ3NSZXNwb25zZSIy2kEGcGFyZW50gtPkkwIjEiEvYXBpL3YxL3twYXJlbnQ9dXNlcnMvKn0vc2V0dGluZ3MSuQEKGExpc3RQZXJzb25hbEFjY2Vzc1Rva2VucxItLm1lbW9zLmFwaS52MS5MaXN0UGVyc29uYWxBY2Nlc3NUb2tlbnNSZXF1ZXN0Gi4ubWVtb3MuYXBpLnYxLkxpc3RQZXJzb25hbEFjY2Vzc1Rva2Vuc1Jlc3BvbnNlIj7aQQZwYXJlbnSC0+STAi8SLS9hcGkvdjEve3BhcmVudD11c2Vycy8qfS9wZXJzb25hbEFjY2Vzc1Rva2VucxK2AQoZQ3JlYXRlUGVyc29uYWxBY2Nlc3NUb2tlbhIuLm1lbW9zLmFwaS52MS5DcmVhdGVQZXJzb25hbEFjY2Vzc1Rva2VuUmVxdWVzdBovLm1lbW9zLmFwaS52MS5DcmVhdGVQZXJzb25hbEFjY2Vzc1Rva2VuUmVzcG9uc2UiOILT5JMCMjoBKiItL2FwaS92MS97cGFyZW50PXVzZXJzLyp9L3BlcnNvbmFsQWNjZXNzVG9rZW5zEqEBChlEZWxldGVQZXJzb25hbEFjY2Vzc1Rva2VuEi4ubWVtb3MuYXBpLnYxLkRlbGV0ZVBlcnNvbmFsQWNjZXNzVG9rZW5SZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IjzaQQRuYW1lgtPkkwIvKi0vYXBpL3YxL3tuYW1lPXVzZXJzLyovcGVyc29uYWxBY2Nlc3NUb2tlbnMvKn0SlQEKEExpc3RVc2VyV2ViaG9va3MSJS5tZW1vcy5hcGkudjEuTGlzdFVzZXJXZWJob29rc1JlcXVlc3QaJi5tZW1vcy5hcGkudjEuTGlzdFVzZXJXZWJob29rc1Jlc3BvbnNlIjLaQQZwYXJlbnSC0+STAiMSIS9hcGkvdjEve3BhcmVudD11c2Vycy8qfS93ZWJob29rcxKbAQoRQ3JlYXRlVXNlcldlYmhvb2sSJi5tZW1vcy5hcGkudjEuQ3JlYXRlVXNlcldlYmhvb2tSZXF1ZXN0GhkubWVtb3MuYXBpLnYxLlVzZXJXZWJob29rIkPaQQ5wYXJlbnQsd2ViaG9va4LT5JMCLDoHd2ViaG9vayIhL2FwaS92MS97cGFyZW50PXVzZXJzLyp9L3dlYmhvb2tzEqgBChFVcGRhdGVVc2VyV2ViaG9vaxImLm1lbW9zLmFwaS52MS5VcGRhdGVVc2VyV2ViaG9va1JlcXVlc3QaGS5tZW1vcy5hcGkudjEuVXNlcldlYmhvb2siUNpBE3dlYmhvb2ssdXBkYXRlX21hc2uC0+STAjQ6B3dlYmhvb2syKS9hcGkvdjEve3dlYmhvb2submFtZT11c2Vycy8qL3dlYmhvb2tzLyp9EoUBChFEZWxldGVVc2VyV2ViaG9vaxImLm1lbW9zLmFwaS52MS5EZWxldGVVc2VyV2ViaG9va1JlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiMNpBBG5hbWWC0+STAiMqIS9hcGkvdjEve25hbWU9dXNlcnMvKi93ZWJob29rcy8qfRKpAQoVTGlzdFVzZXJOb3RpZmljYXRpb25zEioubWVtb3MuYXBpLnYxLkxpc3RVc2VyTm90aWZpY2F0aW9uc1JlcXVlc3QaKy5tZW1vcy5hcGkudjEuTGlzdFVzZXJOb3RpZmljYXRpb25zUmVzcG9uc2UiN9pBBnBhcmVudILT5JMCKBImL2FwaS92MS97cGFyZW50PXVzZXJzLyp9L25vdGlmaWNhdGlvbnMSywEKFlVwZGF0ZVVzZXJOb3RpZmljYXRpb24SKy5tZW1vcy5hcGkudjEuVXBkYXRlVXNlck5vdGlmaWNhdGlvblJlcXVlc3QaHi5tZW1vcy5hcGkudjEuVXNlck5vdGlmaWNhdGlvbiJk2kEYbm90aWZpY2F0aW9uLHVwZGF0ZV9tYXNrgtPkkwJDOgxub3RpZmljYXRpb24yMy9hcGkvdjEve25vdGlmaWNhdGlvbi5uYW1lPXVzZXJzLyovbm90aWZpY2F0aW9ucy8qfRKUAQoWRGVsZXRlVXNlck5vdGlmaWNhdGlvbhIrLm1lbW9zLmFwaS52MS5EZWxldGVVc2VyTm90aWZpY2F0aW9uUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSI12kEEbmFtZYLT5JMCKComL2FwaS92MS97bmFtZT11c2Vycy8qL25vdGlmaWNhdGlvbnMvKn1CqAEKEGNvbS5tZW1vcy5hcGkudjFCEFVzZXJTZXJ2aWNlUHJvdG9QAVowZ2l0aHViLmNvbS91c2VtZW1vcy9tZW1vcy9wcm90by9nZW4vYXBpL3YxO2FwaXYxogIDTUFYqgIMTWVtb3MuQXBpLlYxygIMTWVtb3NcQXBpXFYx4gIYTWVtb3NcQXBpXFYxXEdQQk1ldGFkYXRh6gIOTWVtb3M6OkFwaTo6VjFiBnByb3RvMw", [file_api_v1_common, file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_empty, file_google_protobuf_field_mask, file_google_protobuf_timestamp]);
/** /**
* @generated from message memos.api.v1.User * @generated from message memos.api.v1.User
...@@ -229,10 +229,7 @@ export const ListUsersResponseSchema: GenMessage<ListUsersResponse> = /*@__PURE_ ...@@ -229,10 +229,7 @@ export const ListUsersResponseSchema: GenMessage<ListUsersResponse> = /*@__PURE_
export type GetUserRequest = Message<"memos.api.v1.GetUserRequest"> & { export type GetUserRequest = Message<"memos.api.v1.GetUserRequest"> & {
/** /**
* Required. The resource name of the user. * Required. The resource name of the user.
* Supports both numeric IDs and username strings: * Format: users/{username}
* - users/{id} (e.g., users/101)
* - users/{username} (e.g., users/steven)
* Format: users/{id_or_username}
* *
* @generated from field: string name = 1; * @generated from field: string name = 1;
*/ */
...@@ -510,8 +507,8 @@ export const ListAllUserStatsResponseSchema: GenMessage<ListAllUserStatsResponse ...@@ -510,8 +507,8 @@ export const ListAllUserStatsResponseSchema: GenMessage<ListAllUserStatsResponse
export type UserSetting = Message<"memos.api.v1.UserSetting"> & { export type UserSetting = Message<"memos.api.v1.UserSetting"> & {
/** /**
* The name of the user setting. * The name of the user setting.
* Format: users/{user}/settings/{setting}, {setting} is the key for the setting. * Format: users/{username}/settings/{setting}, {setting} is the key for the setting.
* For example, "users/123/settings/GENERAL" for general settings. * For example, "users/steven/settings/GENERAL" for general settings.
* *
* @generated from field: string name = 1; * @generated from field: string name = 1;
*/ */
...@@ -1363,10 +1360,8 @@ export const UserService: GenService<{ ...@@ -1363,10 +1360,8 @@ export const UserService: GenService<{
output: typeof ListUsersResponseSchema; output: typeof ListUsersResponseSchema;
}, },
/** /**
* GetUser gets a user by ID or username. * GetUser gets a user by username.
* Supports both numeric IDs and username strings: * Format: users/{username} (e.g., users/steven)
* - users/{id} (e.g., users/101)
* - users/{username} (e.g., users/steven)
* *
* @generated from rpc memos.api.v1.UserService.GetUser * @generated from rpc memos.api.v1.UserService.GetUser
*/ */
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment