diff --git a/server/router/api/v1/acl.go b/server/router/api/v1/acl.go index 4b8d5a07e..fdb400297 100644 --- a/server/router/api/v1/acl.go +++ b/server/router/api/v1/acl.go @@ -23,11 +23,16 @@ import ( type ContextKey int const ( - // The key name used to store user's ID in the context (for user-based auth). + // userIDContextKey stores the authenticated user's ID in the context. + // Set for both session-based and token-based authentication. userIDContextKey ContextKey = iota - // The key name used to store session ID in the context (for session-based auth). + + // sessionIDContextKey stores the session ID in the context. + // Only set for session-based authentication (cookie auth). sessionIDContextKey - // The key name used to store access token in the context (for token-based auth). + + // accessTokenContextKey stores the JWT access token in the context. + // Only set for token-based authentication (Bearer token). accessTokenContextKey ) @@ -46,13 +51,26 @@ func NewGRPCAuthInterceptor(store *store.Store, secret string) *GRPCAuthIntercep } // AuthenticationInterceptor is the unary interceptor for gRPC API. +// +// Authentication Strategy (in priority order): +// 1. Session Cookie: Check for "user_session" cookie with format "{userID}-{sessionID}" +// 2. Access Token: Check for "Authorization: Bearer {token}" header with JWT +// 3. Public Endpoints: Allow if method is in public allowlist +// 4. Reject: Return 401 Unauthenticated if none of the above succeed +// +// On successful authentication, sets context values: +// - userIDContextKey: The authenticated user's ID (always set) +// - sessionIDContextKey: Session ID (only for cookie auth) +// - accessTokenContextKey: JWT token (only for Bearer token auth). func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, request any, serverInfo *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { md, ok := metadata.FromIncomingContext(ctx) if !ok { return nil, status.Errorf(codes.Unauthenticated, "failed to parse metadata from incoming context") } - // Try to authenticate via session ID (from cookie) first + // Authentication Method 1: Session-based authentication (Cookie) + // Format: Cookie: user_session={userID}-{sessionID} + // Used by: Web browsers if sessionCookieValue, err := getSessionIDFromMetadata(md); err == nil && sessionCookieValue != "" { user, err := in.authenticateBySession(ctx, sessionCookieValue) if err == nil && user != nil { @@ -65,7 +83,9 @@ func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, re } } - // Try to authenticate via JWT access token (from Authorization header) + // Authentication Method 2: Token-based authentication (JWT) + // Format: Authorization: Bearer {jwt_token} + // Used by: Mobile apps, CLI tools, API clients if accessToken, err := getAccessTokenFromMetadata(md); err == nil && accessToken != "" { user, err := in.authenticateByJWT(ctx, accessToken) if err == nil && user != nil { @@ -73,7 +93,9 @@ func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, re } } - // If no valid authentication found, check if this method is in the allowlist (public endpoints) + // Authentication Method 3: Public endpoints + // Some endpoints don't require authentication (e.g., login, signup) + // Check if this method is in the allowlist if isUnauthorizeAllowedMethod(serverInfo.FullMethod) { return handler(ctx, request) } @@ -109,6 +131,14 @@ func (in *GRPCAuthInterceptor) handleAuthenticatedRequest(ctx context.Context, r } // authenticateByJWT authenticates a user using JWT access token from Authorization header. +// +// Validation steps: +// 1. Parse and verify JWT signature using server secret +// 2. Extract user ID from JWT claims (subject field) +// 3. Verify user exists and is not archived +// 4. Verify token exists in user's access_tokens list (for revocation support) +// +// Returns the authenticated user or an error. func (in *GRPCAuthInterceptor) authenticateByJWT(ctx context.Context, accessToken string) (*store.User, error) { if accessToken == "" { return nil, status.Errorf(codes.Unauthenticated, "access token not found") @@ -160,6 +190,14 @@ func (in *GRPCAuthInterceptor) authenticateByJWT(ctx context.Context, accessToke } // authenticateBySession authenticates a user using session ID from cookie. +// +// Validation steps: +// 1. Parse cookie value to extract userID and sessionID +// 2. Verify user exists and is not archived +// 3. Verify session exists in user's sessions list +// 4. Check session hasn't expired (sliding expiration: 14 days from last access) +// +// Returns the authenticated user or an error. func (in *GRPCAuthInterceptor) authenticateBySession(ctx context.Context, sessionCookieValue string) (*store.User, error) { if sessionCookieValue == "" { return nil, status.Errorf(codes.Unauthenticated, "session cookie value not found") @@ -204,6 +242,11 @@ func (in *GRPCAuthInterceptor) updateSessionLastAccessed(ctx context.Context, us } // validateUserSession checks if a session exists and is still valid using sliding expiration. +// +// Sliding expiration logic: +// - Session is valid if: last_accessed_time + 14 days > current_time +// - Each API call updates last_accessed_time, extending the session +// - This provides better UX than fixed expiration (users stay logged in while active). func validateUserSession(sessionID string, userSessions []*storepb.SessionsUserSetting_Session) bool { for _, session := range userSessions { if sessionID == session.SessionId { @@ -220,7 +263,10 @@ func validateUserSession(sessionID string, userSessions []*storepb.SessionsUserS return false } -// getSessionIDFromMetadata extracts session cookie value from cookie. +// getSessionIDFromMetadata extracts session cookie value from metadata. +// +// Checks both "grpcgateway-cookie" (set by gRPC-Gateway) and "cookie" (set by native gRPC). +// Cookie format: user_session={userID}-{sessionID}. func getSessionIDFromMetadata(md metadata.MD) (string, error) { // Check the cookie header for session cookie value var sessionCookieValue string @@ -238,7 +284,10 @@ func getSessionIDFromMetadata(md metadata.MD) (string, error) { return sessionCookieValue, nil } -// getAccessTokenFromMetadata extracts access token from Authorization header. +// getAccessTokenFromMetadata extracts JWT access token from Authorization header. +// +// Expected header format: Authorization: Bearer {jwt_token} +// This follows the OAuth 2.0 Bearer token specification (RFC 6750). func getAccessTokenFromMetadata(md metadata.MD) (string, error) { // Check the HTTP request Authorization header. authorizationHeaders := md.Get("Authorization") @@ -252,6 +301,10 @@ func getAccessTokenFromMetadata(md metadata.MD) (string, error) { return authHeaderParts[1], nil } +// validateAccessToken checks if the provided JWT token exists in the user's access tokens list. +// +// This enables token revocation: when a user deletes a token from their settings, +// it's removed from this list and subsequent API calls with that token will fail. func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool { for _, userAccessToken := range userAccessTokens { if accessTokenString == userAccessToken.AccessToken { diff --git a/server/router/api/v1/auth.go b/server/router/api/v1/auth.go index d3d5fbb43..78afef35b 100644 --- a/server/router/api/v1/auth.go +++ b/server/router/api/v1/auth.go @@ -12,32 +12,62 @@ import ( ) const ( - // issuer is the issuer of the jwt token. + // Issuer is the issuer claim in JWT tokens. + // This identifies tokens as issued by Memos. Issuer = "memos" - // Signing key section. For now, this is only used for signing, not for verifying since we only - // have 1 version. But it will be used to maintain backward compatibility if we change the signing mechanism. + + // KeyID is the key identifier used in JWT header. + // Version "v1" allows for future key rotation while maintaining backward compatibility. + // If signing mechanism changes, add "v2", "v3", etc. and verify both versions. KeyID = "v1" - // AccessTokenAudienceName is the audience name of the access token. + + // AccessTokenAudienceName is the audience claim for JWT access tokens. + // This ensures tokens are only used for API access, not other purposes. AccessTokenAudienceName = "user.access-token" - // SessionSlidingDuration is the sliding expiration duration for user sessions (2 weeks). - // Sessions are considered valid if last_accessed_time + SessionSlidingDuration > current_time. + + // SessionSlidingDuration is the sliding expiration duration for user sessions. + // Sessions remain valid if accessed within the last 14 days. + // Each API call extends the session by updating last_accessed_time. SessionSlidingDuration = 14 * 24 * time.Hour - // SessionCookieName is the cookie name of user session ID. + // SessionCookieName is the HTTP cookie name used to store session information. + // Cookie value format: {userID}-{sessionID}. SessionCookieName = "user_session" ) +// ClaimsMessage represents the claims structure in a JWT token. +// +// JWT Claims include: +// - name: Username (custom claim) +// - iss: Issuer = "memos" +// - aud: Audience = "user.access-token" +// - sub: Subject = user ID +// - iat: Issued at time +// - exp: Expiration time (optional, may be empty for never-expiring tokens). type ClaimsMessage struct { - Name string `json:"name"` + Name string `json:"name"` // Username jwt.RegisteredClaims } -// GenerateAccessToken generates an access token. +// GenerateAccessToken generates a JWT access token for a user. +// +// Parameters: +// - username: The user's username (stored in "name" claim) +// - userID: The user's ID (stored in "sub" claim) +// - expirationTime: When the token expires (pass zero time for no expiration) +// - secret: Server secret used to sign the token +// +// Returns a signed JWT string or an error. func GenerateAccessToken(username string, userID int32, expirationTime time.Time, secret []byte) (string, error) { return generateToken(username, userID, AccessTokenAudienceName, expirationTime, secret) } -// generateToken generates a jwt token. +// generateToken generates a JWT token with the given claims. +// +// Token structure: +// Header: {"alg": "HS256", "kid": "v1", "typ": "JWT"} +// Claims: {"name": username, "iss": "memos", "aud": [audience], "sub": userID, "iat": now, "exp": expiry} +// Signature: HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret). func generateToken(username string, userID int32, audience string, expirationTime time.Time, secret []byte) (string, error) { registeredClaims := jwt.RegisteredClaims{ Issuer: Issuer, @@ -65,17 +95,31 @@ func generateToken(username string, userID int32, audience string, expirationTim return tokenString, nil } -// GenerateSessionID generates a unique session ID using UUIDv4. +// GenerateSessionID generates a unique session ID. +// +// Uses UUID v4 (random) for high entropy and uniqueness. +// Session IDs are stored in user settings and used to identify browser sessions. func GenerateSessionID() (string, error) { return util.GenUUID(), nil } -// BuildSessionCookieValue builds the session cookie value in format {userID}-{sessionID}. +// BuildSessionCookieValue creates the session cookie value. +// +// Format: {userID}-{sessionID} +// Example: "123-550e8400-e29b-41d4-a716-446655440000" +// +// This format allows quick extraction of both user ID and session ID +// from the cookie without database lookup during authentication. func BuildSessionCookieValue(userID int32, sessionID string) string { return fmt.Sprintf("%d-%s", userID, sessionID) } -// ParseSessionCookieValue parses the session cookie value to extract userID and sessionID. +// ParseSessionCookieValue extracts user ID and session ID from cookie value. +// +// Input format: "{userID}-{sessionID}" +// Returns: (userID, sessionID, error) +// +// Example: "123-550e8400-..." → (123, "550e8400-...", nil). func ParseSessionCookieValue(cookieValue string) (int32, string, error) { parts := strings.SplitN(cookieValue, "-", 2) if len(parts) != 2 { diff --git a/server/router/api/v1/auth_service.go b/server/router/api/v1/auth_service.go index 5380bbfcb..364b87ec2 100644 --- a/server/router/api/v1/auth_service.go +++ b/server/router/api/v1/auth_service.go @@ -29,6 +29,15 @@ const ( unmatchedUsernameAndPasswordError = "unmatched username and password" ) +// GetCurrentSession retrieves the current authenticated session information. +// +// This endpoint is used to: +// - Check if a user is currently authenticated +// - Get the current user's information +// - Retrieve the last accessed time of the session +// +// Authentication: Required (session cookie or access token) +// Returns: User information and last accessed timestamp. func (s *APIV1Service) GetCurrentSession(ctx context.Context, _ *v1pb.GetCurrentSessionRequest) (*v1pb.GetCurrentSessionResponse, error) { user, err := s.GetCurrentUser(ctx) if err != nil { @@ -59,8 +68,23 @@ func (s *APIV1Service) GetCurrentSession(ctx context.Context, _ *v1pb.GetCurrent }, nil } +// CreateSession authenticates a user and establishes a new session. +// +// This endpoint supports two authentication methods: +// 1. Password-based authentication (username + password) +// 2. SSO authentication (OAuth2 authorization code) +// +// On successful authentication: +// - A session cookie is set for web browsers (cookie: user_session={userID}-{sessionID}) +// - Session information is stored including client details (IP, user agent, device type) +// - Sessions use sliding expiration: 14 days from last access +// +// Authentication: Not required (public endpoint) +// Returns: Authenticated user information and last accessed timestamp. func (s *APIV1Service) CreateSession(ctx context.Context, request *v1pb.CreateSessionRequest) (*v1pb.CreateSessionResponse, error) { var existingUser *store.User + + // Authentication Method 1: Password-based authentication if passwordCredentials := request.GetPasswordCredentials(); passwordCredentials != nil { user, err := s.Store.GetUser(ctx, &store.FindUser{ Username: &passwordCredentials.Username, @@ -85,6 +109,7 @@ func (s *APIV1Service) CreateSession(ctx context.Context, request *v1pb.CreateSe } existingUser = user } else if ssoCredentials := request.GetSsoCredentials(); ssoCredentials != nil { + // Authentication Method 2: SSO (OAuth2) authentication identityProvider, err := s.Store.GetIdentityProvider(ctx, &store.FindIdentityProvider{ ID: &ssoCredentials.IdpId, }) @@ -183,6 +208,16 @@ func (s *APIV1Service) CreateSession(ctx context.Context, request *v1pb.CreateSe }, nil } +// doSignIn performs the actual sign-in operation by creating a session and setting the cookie. +// +// This function: +// 1. Generates a unique session ID (UUID) +// 2. Tracks the session in user settings with client information +// 3. Sets a session cookie in the format: {userID}-{sessionID} +// 4. Configures cookie security settings (HttpOnly, Secure, SameSite) +// +// Cookie lifetime is 100 years, but actual session validity is controlled by +// sliding expiration (14 days from last access) checked during authentication. func (s *APIV1Service) doSignIn(ctx context.Context, user *store.User, expireTime time.Time) error { // Generate unique session ID for web use sessionID, err := GenerateSessionID() @@ -212,6 +247,14 @@ func (s *APIV1Service) doSignIn(ctx context.Context, user *store.User, expireTim return nil } +// DeleteSession terminates the current user session (logout). +// +// This endpoint: +// 1. Removes the session from the user's sessions list in the database +// 2. Clears the session cookie by setting it to expire immediately +// +// Authentication: Required (session cookie or access token) +// Returns: Empty response on success. func (s *APIV1Service) DeleteSession(ctx context.Context, _ *v1pb.DeleteSessionRequest) (*emptypb.Empty, error) { user, err := s.GetCurrentUser(ctx) if err != nil { @@ -298,7 +341,13 @@ func (s *APIV1Service) GetCurrentUser(ctx context.Context) (*store.User, error) return user, nil } -// Helper function to track user session for session management. +// trackUserSession creates a new session record in the user's settings. +// +// Session information includes: +// - session_id: Unique UUID for this session +// - create_time: When the session was created +// - last_accessed_time: When the session was last used (for sliding expiration) +// - client_info: Device details (user agent, IP, device type, OS, browser). func (s *APIV1Service) trackUserSession(ctx context.Context, userID int32, sessionID string) error { // Extract client information from the context clientInfo := s.extractClientInfo(ctx) @@ -313,19 +362,19 @@ func (s *APIV1Service) trackUserSession(ctx context.Context, userID int32, sessi return s.Store.AddUserSession(ctx, userID, session) } -// Helper function to extract client information from the gRPC context. // extractClientInfo extracts comprehensive client information from the request context. -// This includes user agent parsing to determine device type, operating system, browser, -// and IP address extraction. This information is used to provide detailed session -// tracking and management capabilities in the web UI. // -// Fields populated: -// - UserAgent: Raw user agent string -// - IpAddress: Client IP (from X-Forwarded-For or X-Real-IP headers) -// - DeviceType: "mobile", "tablet", or "desktop" -// - Os: Operating system name and version (e.g., "iOS 17.1", "Windows 10/11") +// This function parses metadata from the gRPC context to extract: +// - User Agent: Raw user agent string for detailed parsing +// - IP Address: Client IP from X-Forwarded-For or X-Real-IP headers +// - Device Type: "mobile", "tablet", or "desktop" (parsed from user agent) +// - Operating System: OS name and version (e.g., "iOS 17.1", "Windows 10/11") // - Browser: Browser name and version (e.g., "Chrome 120.0.0.0") -// - Country: Geographic location (TODO: implement with GeoIP service). +// +// This information enables users to: +// - See all active sessions with device details +// - Identify suspicious login attempts +// - Revoke specific sessions from unknown devices. func (s *APIV1Service) extractClientInfo(ctx context.Context) *storepb.SessionsUserSetting_ClientInfo { clientInfo := &storepb.SessionsUserSetting_ClientInfo{} @@ -351,6 +400,14 @@ func (s *APIV1Service) extractClientInfo(ctx context.Context) *storepb.SessionsU } // parseUserAgent extracts device type, OS, and browser information from user agent string. +// +// Detection logic: +// - Device Type: Checks for keywords like "mobile", "tablet", "ipad" +// - OS: Pattern matches for iOS, Android, Windows, macOS, Linux, Chrome OS +// - Browser: Identifies Edge, Chrome, Firefox, Safari, Opera +// +// Note: This is a simplified parser. For production use with high accuracy requirements, +// consider using a dedicated user agent parsing library. func (*APIV1Service) parseUserAgent(userAgent string, clientInfo *storepb.SessionsUserSetting_ClientInfo) { if userAgent == "" { return diff --git a/server/router/api/v1/user_service.go b/server/router/api/v1/user_service.go index 3bead551e..1f926df84 100644 --- a/server/router/api/v1/user_service.go +++ b/server/router/api/v1/user_service.go @@ -521,6 +521,21 @@ func (s *APIV1Service) ListUserSettings(ctx context.Context, request *v1pb.ListU return response, nil } +// ListUserAccessTokens retrieves all Personal Access Tokens (PATs) for a user. +// +// Personal Access Tokens are used for: +// - Mobile app authentication +// - CLI tool authentication +// - API client authentication +// - Any programmatic access requiring Bearer token auth +// +// Security: +// - Only the token owner can list their tokens +// - Returns full token strings (so users can manage/revoke them) +// - Invalid or expired tokens are filtered out +// +// Authentication: Required (session cookie or access token) +// Authorization: User can only list their own tokens. func (s *APIV1Service) ListUserAccessTokens(ctx context.Context, request *v1pb.ListUserAccessTokensRequest) (*v1pb.ListUserAccessTokensResponse, error) { userID, err := ExtractUserIDFromName(request.Parent) if err != nil { @@ -584,6 +599,26 @@ func (s *APIV1Service) ListUserAccessTokens(ctx context.Context, request *v1pb.L return response, nil } +// CreateUserAccessToken creates a new Personal Access Token (PAT) for a user. +// +// Use cases: +// - User manually creates token in settings for mobile app +// - User creates token for CLI tool +// - User creates token for third-party integration +// +// Token properties: +// - JWT format signed with server secret +// - Contains user ID and username in claims +// - Optional expiration time (can be never-expiring) +// - User-provided description for identification +// +// Security considerations: +// - Full token is only shown ONCE (in this response) +// - User should copy and store it securely +// - Token can be revoked by deleting it from settings +// +// Authentication: Required (session cookie or access token) +// Authorization: User can only create tokens for themselves. func (s *APIV1Service) CreateUserAccessToken(ctx context.Context, request *v1pb.CreateUserAccessTokenRequest) (*v1pb.UserAccessToken, error) { userID, err := ExtractUserIDFromName(request.Parent) if err != nil { @@ -643,6 +678,19 @@ func (s *APIV1Service) CreateUserAccessToken(ctx context.Context, request *v1pb. return userAccessToken, nil } +// DeleteUserAccessToken revokes a Personal Access Token. +// +// This endpoint: +// 1. Removes the token from the user's access tokens list +// 2. Immediately invalidates the token (subsequent API calls with it will fail) +// +// Use cases: +// - User revokes a compromised token +// - User removes token for unused app/device +// - User cleans up old tokens +// +// Authentication: Required (session cookie or access token) +// Authorization: User can only delete their own tokens. func (s *APIV1Service) DeleteUserAccessToken(ctx context.Context, request *v1pb.DeleteUserAccessTokenRequest) (*emptypb.Empty, error) { // Extract user ID from the access token resource name // Format: users/{user}/accessTokens/{access_token} @@ -694,6 +742,21 @@ func (s *APIV1Service) DeleteUserAccessToken(ctx context.Context, request *v1pb. return &emptypb.Empty{}, nil } +// ListUserSessions retrieves all active sessions for a user. +// +// Sessions represent active browser logins. Each session includes: +// - session_id: Unique identifier +// - create_time: When the session was created +// - last_accessed_time: Last API call time (for sliding expiration) +// - client_info: Device details (browser, OS, IP address, device type) +// +// Use cases: +// - User reviews where they're logged in +// - User identifies suspicious login attempts +// - User prepares to revoke specific sessions +// +// Authentication: Required (session cookie or access token) +// Authorization: User can only list their own sessions. func (s *APIV1Service) ListUserSessions(ctx context.Context, request *v1pb.ListUserSessionsRequest) (*v1pb.ListUserSessionsResponse, error) { userID, err := ExtractUserIDFromName(request.Parent) if err != nil { @@ -749,6 +812,23 @@ func (s *APIV1Service) ListUserSessions(ctx context.Context, request *v1pb.ListU return response, nil } +// RevokeUserSession terminates a specific session for a user. +// +// This endpoint: +// 1. Removes the session from the user's sessions list +// 2. Immediately invalidates the session +// 3. Forces the device to re-login on next request +// +// Use cases: +// - User logs out from a specific device (e.g., "Log out my phone") +// - User removes suspicious/unknown session +// - User logs out from all devices except current one +// +// Note: This is different from DeleteSession (logout current session). +// This endpoint allows revoking ANY session, not just the current one. +// +// Authentication: Required (session cookie or access token) +// Authorization: User can only revoke their own sessions. func (s *APIV1Service) RevokeUserSession(ctx context.Context, request *v1pb.RevokeUserSessionRequest) (*emptypb.Empty, error) { // Extract user ID and session ID from the session resource name // Format: users/{user}/sessions/{session} diff --git a/store/seed/sqlite/01__dump.sql b/store/seed/sqlite/01__dump.sql index de3b442d3..fb601d698 100644 --- a/store/seed/sqlite/01__dump.sql +++ b/store/seed/sqlite/01__dump.sql @@ -22,12 +22,12 @@ INSERT INTO memo (id,uid,creator_id,content,visibility,pinned,payload) VALUES(6, -- Memo Relations INSERT INTO memo_relation VALUES(3,1,'REFERENCE'); --- Reactions -INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(1,1,'memos/1','🎉'); -INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(2,1,'memos/1','👍'); -INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(3,1,'memos/2','✅'); -INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(4,1,'memos/5','💡'); -INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(5,1,'memos/6','🚀'); +-- Reactions (using memo UIDs, not numeric IDs) +INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(1,1,'memos/welcome2memos001','🎉'); +INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(2,1,'memos/welcome2memos001','👍'); +INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(3,1,'memos/taskdemo000001','✅'); +INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(4,1,'memos/idea00000001','💡'); +INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(5,1,'memos/sponsor0000001','🚀'); -- System Settings INSERT INTO system_setting VALUES ('MEMO_RELATED', '{"contentLengthLimit":8192,"enableAutoCompact":true,"enableComment":true,"enableLocation":true,"defaultVisibility":"PUBLIC","reactions":["👍","💛","🔥","👏","😂","👌","🚀","👀","🤔","🤡","❓","+1","🎉","💡","✅"]}', '');