Remove extra jwt claims (for user settings) (#1025)

* Remove extra jwt claims (for user settings)

- The JWT token only contains the issuer, and your user id now.
- Now only a page refresh is necessary to pick up your settings on all
  clients, including theme, language, etc.
- GetSiteResponse now gives you your user and settings if logged in.
- Fixes #773

* Remove extra comment line, I tested nsfw

* Adding a todo to add a User_::readSafe()
This commit is contained in:
Dessalines 2020-07-27 09:23:08 -04:00 committed by GitHub
parent 571d0a6500
commit d1342afe93
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 348 additions and 258 deletions

View file

@ -942,6 +942,10 @@ Search types are `All, Comments, Posts, Communities, Users, Url`
```rust
{
op: "GetSite"
data: {
auth: Option<String>,
}
}
```
##### Response
@ -954,6 +958,7 @@ Search types are `All, Comments, Posts, Communities, Users, Url`
banned: Vec<UserView>,
online: usize, // This is currently broken
version: String,
my_user: Option<User_>, // Gives back your user and settings if logged in
}
}
```

View file

@ -6,8 +6,9 @@ use crate::{
};
use bcrypt::{hash, DEFAULT_COST};
use diesel::{dsl::*, result::Error, *};
use serde::{Deserialize, Serialize};
#[derive(Clone, Queryable, Identifiable, PartialEq, Debug)]
#[derive(Clone, Queryable, Identifiable, PartialEq, Debug, Serialize, Deserialize)]
#[table_name = "user_"]
pub struct User_ {
pub id: i32,

View file

@ -56,14 +56,14 @@ pub struct UserView {
pub actor_id: String,
pub name: String,
pub avatar: Option<String>,
pub email: Option<String>,
pub email: Option<String>, // TODO this shouldn't be in this view
pub matrix_user_id: Option<String>,
pub bio: Option<String>,
pub local: bool,
pub admin: bool,
pub banned: bool,
pub show_avatars: bool,
pub send_notifications_to_email: bool,
pub show_avatars: bool, // TODO this is a setting, probably doesn't need to be here
pub send_notifications_to_email: bool, // TODO also never used
pub published: chrono::NaiveDateTime,
pub number_of_posts: i64,
pub post_score: i64,

View file

@ -9,15 +9,7 @@ type Jwt = String;
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub id: i32,
pub username: String,
pub iss: String,
pub show_nsfw: bool,
pub theme: String,
pub default_sort_type: i16,
pub default_listing_type: i16,
pub lang: String,
pub avatar: Option<String>,
pub show_avatars: bool,
}
impl Claims {
@ -36,15 +28,7 @@ impl Claims {
pub fn jwt(user: User_, hostname: String) -> Jwt {
let my_claims = Claims {
id: user.id,
username: user.name.to_owned(),
iss: hostname,
show_nsfw: user.show_nsfw,
theme: user.theme.to_owned(),
default_sort_type: user.default_sort_type,
default_listing_type: user.default_listing_type,
lang: user.lang.to_owned(),
avatar: user.avatar.to_owned(),
show_avatars: user.show_avatars.to_owned(),
};
encode(
&Header::default(),

View file

@ -591,21 +591,26 @@ impl Perform for Oper<ListCommunities> {
) -> Result<ListCommunitiesResponse, LemmyError> {
let data: &ListCommunities = &self.data;
let user_claims: Option<Claims> = match &data.auth {
// For logged in users, you need to get back subscribed, and settings
let user: Option<User_> = match &data.auth {
Some(auth) => match Claims::decode(&auth) {
Ok(claims) => Some(claims.claims),
Ok(claims) => {
let user_id = claims.claims.id;
let user = blocking(pool, move |conn| User_::read(conn, user_id)).await??;
Some(user)
}
Err(_e) => None,
},
None => None,
};
let user_id = match &user_claims {
Some(claims) => Some(claims.id),
let user_id = match &user {
Some(user) => Some(user.id),
None => None,
};
let show_nsfw = match &user_claims {
Some(claims) => claims.show_nsfw,
let show_nsfw = match &user {
Some(user) => user.show_nsfw,
None => false,
};

View file

@ -370,21 +370,26 @@ impl Perform for Oper<GetPosts> {
) -> Result<GetPostsResponse, LemmyError> {
let data: &GetPosts = &self.data;
let user_claims: Option<Claims> = match &data.auth {
// For logged in users, you need to get back subscribed, and settings
let user: Option<User_> = match &data.auth {
Some(auth) => match Claims::decode(&auth) {
Ok(claims) => Some(claims.claims),
Ok(claims) => {
let user_id = claims.claims.id;
let user = blocking(pool, move |conn| User_::read(conn, user_id)).await??;
Some(user)
}
Err(_e) => None,
},
None => None,
};
let user_id = match &user_claims {
Some(claims) => Some(claims.id),
let user_id = match &user {
Some(user) => Some(user.id),
None => None,
};
let show_nsfw = match &user_claims {
Some(claims) => claims.show_nsfw,
let show_nsfw = match &user {
Some(user) => user.show_nsfw,
None => false,
};

View file

@ -18,6 +18,7 @@ use lemmy_db::{
post_view::*,
site::*,
site_view::*,
user::*,
user_view::*,
Crud,
SearchType,
@ -98,7 +99,9 @@ pub struct EditSite {
}
#[derive(Serialize, Deserialize)]
pub struct GetSite {}
pub struct GetSite {
auth: Option<String>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct SiteResponse {
@ -112,6 +115,7 @@ pub struct GetSiteResponse {
banned: Vec<UserView>,
pub online: usize,
version: String,
my_user: Option<User_>,
}
#[derive(Serialize, Deserialize)]
@ -352,7 +356,7 @@ impl Perform for Oper<GetSite> {
pool: &DbPool,
websocket_info: Option<WebsocketInfo>,
) -> Result<GetSiteResponse, LemmyError> {
let _data: &GetSite = &self.data;
let data: &GetSite = &self.data;
// TODO refactor this a little
let res = blocking(pool, move |conn| Site::read(conn, 1)).await?;
@ -415,12 +419,29 @@ impl Perform for Oper<GetSite> {
0
};
// Giving back your user, if you're logged in
let my_user: Option<User_> = match &data.auth {
Some(auth) => match Claims::decode(&auth) {
Ok(claims) => {
let user_id = claims.claims.id;
let mut user = blocking(pool, move |conn| User_::read(conn, user_id)).await??;
user.password_encrypted = "".to_string();
user.private_key = None;
user.public_key = None;
Some(user)
}
Err(_e) => None,
},
None => None,
};
Ok(GetSiteResponse {
site: site_view,
admins,
banned,
online,
version: version::VERSION.to_string(),
my_user,
})
}
}
@ -614,6 +635,11 @@ impl Perform for Oper<TransferSite> {
};
let user_id = claims.id;
let mut user = blocking(pool, move |conn| User_::read(conn, user_id)).await??;
// TODO add a User_::read_safe() for this.
user.password_encrypted = "".to_string();
user.private_key = None;
user.public_key = None;
let read_site = blocking(pool, move |conn| Site::read(conn, 1)).await??;
@ -664,6 +690,7 @@ impl Perform for Oper<TransferSite> {
banned,
online: 0,
version: version::VERSION.to_string(),
my_user: Some(user),
})
}
}

View file

@ -561,21 +561,26 @@ impl Perform for Oper<GetUserDetails> {
) -> Result<GetUserDetailsResponse, LemmyError> {
let data: &GetUserDetails = &self.data;
let user_claims: Option<Claims> = match &data.auth {
// For logged in users, you need to get back subscribed, and settings
let user: Option<User_> = match &data.auth {
Some(auth) => match Claims::decode(&auth) {
Ok(claims) => Some(claims.claims),
Ok(claims) => {
let user_id = claims.claims.id;
let user = blocking(pool, move |conn| User_::read(conn, user_id)).await??;
Some(user)
}
Err(_e) => None,
},
None => None,
};
let user_id = match &user_claims {
Some(claims) => Some(claims.id),
let user_id = match &user {
Some(user) => Some(user.id),
None => None,
};
let show_nsfw = match &user_claims {
Some(claims) => claims.show_nsfw,
let show_nsfw = match &user {
Some(user) => user.show_nsfw,
None => false,
};
@ -1188,11 +1193,11 @@ impl Perform for Oper<CreatePrivateMessage> {
let subject = &format!(
"{} - Private Message from {}",
Settings::get().hostname,
claims.username
user.name,
);
let html = &format!(
"<h1>Private Message</h1><br><div>{} - {}</div><br><a href={}/inbox>inbox</a>",
claims.username, &content_slurs_removed, hostname
user.name, &content_slurs_removed, hostname
);
match send_email(subject, &email, &recipient_user.name, html) {
Ok(_o) => _o,

View file

@ -559,17 +559,14 @@ export class Inbox extends Component<any, InboxState> {
let data = res.data as GetSiteResponse;
this.state.enableDownvotes = data.site.enable_downvotes;
this.setState(this.state);
document.title = `/u/${UserService.Instance.user.username} ${i18n.t(
document.title = `/u/${UserService.Instance.user.name} ${i18n.t(
'inbox'
)} - ${data.site.name}`;
}
}
sendUnreadCount() {
UserService.Instance.user.unreadCount = this.unreadCount();
UserService.Instance.sub.next({
user: UserService.Instance.user,
});
UserService.Instance.unreadCountSub.next(this.unreadCount());
}
unreadCount(): number {

View file

@ -29,8 +29,9 @@ import {
toast,
messageToastify,
md,
setTheme,
} from '../utils';
import { i18n } from '../i18next';
import { i18n, i18nextSetup } from '../i18next';
interface NavbarState {
isLoggedIn: boolean;
@ -44,14 +45,16 @@ interface NavbarState {
admins: Array<UserView>;
searchParam: string;
toggleSearch: boolean;
siteLoading: boolean;
}
export class Navbar extends Component<any, NavbarState> {
private wsSub: Subscription;
private userSub: Subscription;
private unreadCountSub: Subscription;
private searchTextField: RefObject<HTMLInputElement>;
emptyState: NavbarState = {
isLoggedIn: UserService.Instance.user !== undefined,
isLoggedIn: false,
unreadCount: 0,
replies: [],
mentions: [],
@ -62,22 +65,13 @@ export class Navbar extends Component<any, NavbarState> {
admins: [],
searchParam: '',
toggleSearch: false,
siteLoading: true,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
// Subscribe to user changes
this.userSub = UserService.Instance.sub.subscribe(user => {
this.state.isLoggedIn = user.user !== undefined;
if (this.state.isLoggedIn) {
this.state.unreadCount = user.user.unreadCount;
this.requestNotificationPermission();
}
this.setState(this.state);
});
this.wsSub = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
@ -86,17 +80,30 @@ export class Navbar extends Component<any, NavbarState> {
() => console.log('complete')
);
if (this.state.isLoggedIn) {
this.requestNotificationPermission();
// TODO couldn't get re-logging in to re-fetch unreads
this.fetchUnreads();
}
WebSocketService.Instance.getSite();
this.searchTextField = createRef();
}
componentDidMount() {
// Subscribe to jwt changes
this.userSub = UserService.Instance.jwtSub.subscribe(res => {
// A login
if (res !== undefined) {
this.requestNotificationPermission();
} else {
this.state.isLoggedIn = false;
}
WebSocketService.Instance.getSite();
this.setState(this.state);
});
// Subscribe to unread count changes
this.unreadCountSub = UserService.Instance.unreadCountSub.subscribe(res => {
this.setState({ unreadCount: res });
});
}
handleSearchParam(i: Navbar, event: any) {
i.state.searchParam = event.target.value;
i.setState(i.state);
@ -145,6 +152,7 @@ export class Navbar extends Component<any, NavbarState> {
componentWillUnmount() {
this.wsSub.unsubscribe();
this.userSub.unsubscribe();
this.unreadCountSub.unsubscribe();
}
// TODO class active corresponding to current page
@ -152,9 +160,17 @@ export class Navbar extends Component<any, NavbarState> {
return (
<nav class="navbar navbar-expand-lg navbar-light shadow-sm p-0 px-3">
<div class="container">
{!this.state.siteLoading ? (
<Link title={this.state.version} class="navbar-brand" to="/">
{this.state.siteName}
</Link>
) : (
<div class="navbar-item">
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
</div>
)}
{this.state.isLoggedIn && (
<Link
class="ml-auto p-0 navbar-toggler nav-link border-0"
@ -180,8 +196,11 @@ export class Navbar extends Component<any, NavbarState> {
>
<span class="navbar-toggler-icon"></span>
</button>
{!this.state.siteLoading && (
<div
className={`${!this.state.expanded && 'collapse'} navbar-collapse`}
className={`${
!this.state.expanded && 'collapse'
} navbar-collapse`}
>
<ul class="navbar-nav my-2 mr-auto">
<li class="nav-item">
@ -275,7 +294,11 @@ export class Navbar extends Component<any, NavbarState> {
<>
<ul class="navbar-nav my-2">
<li className="nav-item">
<Link class="nav-link" to="/inbox" title={i18n.t('inbox')}>
<Link
class="nav-link"
to="/inbox"
title={i18n.t('inbox')}
>
<svg class="icon">
<use xlinkHref="#icon-bell"></use>
</svg>
@ -291,11 +314,12 @@ export class Navbar extends Component<any, NavbarState> {
<li className="nav-item">
<Link
class="nav-link"
to={`/u/${UserService.Instance.user.username}`}
to={`/u/${UserService.Instance.user.name}`}
title={i18n.t('settings')}
>
<span>
{UserService.Instance.user.avatar && showAvatars() && (
{UserService.Instance.user.avatar &&
showAvatars() && (
<img
src={pictrsAvatarThumbnail(
UserService.Instance.user.avatar
@ -305,7 +329,7 @@ export class Navbar extends Component<any, NavbarState> {
class="rounded-circle mr-2"
/>
)}
{UserService.Instance.user.username}
{UserService.Instance.user.name}
</span>
</Link>
</li>
@ -325,6 +349,7 @@ export class Navbar extends Component<any, NavbarState> {
</ul>
)}
</div>
)}
</div>
</nav>
);
@ -400,13 +425,29 @@ export class Navbar extends Component<any, NavbarState> {
this.state.siteName = data.site.name;
this.state.version = data.version;
this.state.admins = data.admins;
this.setState(this.state);
}
// The login
if (data.my_user) {
UserService.Instance.user = data.my_user;
// On the first load, check the unreads
if (this.state.isLoggedIn == false) {
this.requestNotificationPermission();
this.fetchUnreads();
setTheme(data.my_user.theme, true);
}
this.state.isLoggedIn = true;
}
i18nextSetup();
this.state.siteLoading = false;
this.setState(this.state);
}
}
fetchUnreads() {
if (this.state.isLoggedIn) {
console.log('Fetching unreads...');
let repliesForm: GetRepliesForm = {
sort: SortType[SortType.New],
unread_only: true,
@ -433,17 +474,13 @@ export class Navbar extends Component<any, NavbarState> {
WebSocketService.Instance.getPrivateMessages(privateMessagesForm);
}
}
}
get currentLocation() {
return this.context.router.history.location.pathname;
}
sendUnreadCount() {
UserService.Instance.user.unreadCount = this.state.unreadCount;
UserService.Instance.sub.next({
user: UserService.Instance.user,
});
UserService.Instance.unreadCountSub.next(this.state.unreadCount);
}
calculateUnreadCount(): number {

View file

@ -174,10 +174,9 @@ export class Post extends Component<any, PostState> {
auth: null,
};
WebSocketService.Instance.markCommentAsRead(form);
UserService.Instance.user.unreadCount--;
UserService.Instance.sub.next({
user: UserService.Instance.user,
});
UserService.Instance.unreadCountSub.next(
UserService.Instance.unreadCountSub.value - 1
);
}
}

7
ui/src/i18next.ts vendored
View file

@ -65,7 +65,8 @@ function format(value: any, format: any, lng: any): any {
return format === 'uppercase' ? value.toUpperCase() : value;
}
i18next.init({
export function i18nextSetup() {
i18next.init({
debug: false,
// load: 'languageOnly',
@ -74,6 +75,6 @@ i18next.init({
fallbackLng: 'en',
resources,
interpolation: { format },
});
});
}
export { i18next as i18n, resources };

31
ui/src/interfaces.ts vendored
View file

@ -100,18 +100,33 @@ export enum SearchType {
Url,
}
export interface User {
export interface Claims {
id: number;
iss: string;
username: string;
}
export interface User {
id: number;
name: string;
preferred_username?: string;
email?: string;
avatar?: string;
admin: boolean;
banned: boolean;
published: string;
updated?: string;
show_nsfw: boolean;
theme: string;
default_sort_type: SortType;
default_listing_type: ListingType;
lang: string;
avatar?: string;
show_avatars: boolean;
unreadCount?: number;
send_notifications_to_email: boolean;
matrix_user_id?: string;
actor_id: string;
bio?: string;
local: boolean;
last_refreshed_at: string;
}
export interface UserView {
@ -797,6 +812,10 @@ export interface GetSiteConfig {
auth?: string;
}
export interface GetSiteForm {
auth?: string;
}
export interface GetSiteConfigResponse {
config_hjson: string;
}
@ -812,6 +831,7 @@ export interface GetSiteResponse {
banned: Array<UserView>;
online: number;
version: string;
my_user?: User;
}
export interface SiteResponse {
@ -998,7 +1018,8 @@ type ResponseType =
| AddAdminResponse
| PrivateMessageResponse
| PrivateMessagesResponse
| GetSiteConfigResponse;
| GetSiteConfigResponse
| GetSiteResponse;
export interface WebSocketResponse {
op: UserOperation;

View file

@ -1,20 +1,22 @@
import Cookies from 'js-cookie';
import { User, LoginResponse } from '../interfaces';
import { User, Claims, LoginResponse } from '../interfaces';
import { setTheme } from '../utils';
import jwt_decode from 'jwt-decode';
import { Subject } from 'rxjs';
import { Subject, BehaviorSubject } from 'rxjs';
export class UserService {
private static _instance: UserService;
public user: User;
public sub: Subject<{ user: User }> = new Subject<{
user: User;
}>();
public claims: Claims;
public jwtSub: Subject<string> = new Subject<string>();
public unreadCountSub: BehaviorSubject<number> = new BehaviorSubject<number>(
0
);
private constructor() {
let jwt = Cookies.get('jwt');
if (jwt) {
this.setUser(jwt);
this.setClaims(jwt);
} else {
setTheme();
console.log('No JWT cookie found.');
@ -22,16 +24,17 @@ export class UserService {
}
public login(res: LoginResponse) {
this.setUser(res.jwt);
this.setClaims(res.jwt);
Cookies.set('jwt', res.jwt, { expires: 365 });
console.log('jwt cookie set');
}
public logout() {
this.claims = undefined;
this.user = undefined;
Cookies.remove('jwt');
setTheme();
this.sub.next({ user: undefined });
this.jwtSub.next(undefined);
console.log('Logged out.');
}
@ -39,11 +42,9 @@ export class UserService {
return Cookies.get('jwt');
}
private setUser(jwt: string) {
this.user = jwt_decode(jwt);
setTheme(this.user.theme, true);
this.sub.next({ user: this.user });
console.log(this.user);
private setClaims(jwt: string) {
this.claims = jwt_decode(jwt);
this.jwtSub.next(jwt);
}
public static get Instance() {

View file

@ -51,6 +51,7 @@ import {
GetCommentsForm,
UserJoinForm,
GetSiteConfig,
GetSiteForm,
SiteConfigForm,
MessageType,
WebSocketJsonResponse,
@ -316,8 +317,9 @@ export class WebSocketService {
this.ws.send(this.wsSendWrapper(UserOperation.EditSite, siteForm));
}
public getSite() {
this.ws.send(this.wsSendWrapper(UserOperation.GetSite, {}));
public getSite(form: GetSiteForm = {}) {
this.setAuth(form, false);
this.ws.send(this.wsSendWrapper(UserOperation.GetSite, form));
}
public getSiteConfig() {