mirror of
https://github.com/Blah-IM/blahrs.git
synced 2025-05-01 00:31:09 +00:00
Impl /room
and /room/{ruuid}/admin
endpoints
This commit is contained in:
parent
e84b13c876
commit
5d15900436
5 changed files with 245 additions and 29 deletions
|
@ -4,6 +4,23 @@ info:
|
||||||
version: 0.0.1
|
version: 0.0.1
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
|
/room:
|
||||||
|
get:
|
||||||
|
summary: Get room metadata
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RoomMetadata'
|
||||||
|
404:
|
||||||
|
description: |
|
||||||
|
Room does not exist or the user does not have permission to get metadata of it.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ApiError'
|
||||||
|
|
||||||
/room/create:
|
/room/create:
|
||||||
post:
|
post:
|
||||||
summary: Create a new room
|
summary: Create a new room
|
||||||
|
@ -144,6 +161,36 @@ paths:
|
||||||
application/json:
|
application/json:
|
||||||
$ref: '#/components/schemas/ApiError'
|
$ref: '#/components/schemas/ApiError'
|
||||||
|
|
||||||
|
/room/{ruuid}/admin:
|
||||||
|
post:
|
||||||
|
summary: Room management
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: WithSig<ChatPayload>
|
||||||
|
example:
|
||||||
|
sig: 99a77e836538268839ed3419c649eefb043cb51d448f641cc2a1c523811aab4aacd09f92e7c0688ffd659bfc6acb764fea79979a491e132bf6a56dd23adc1d09
|
||||||
|
signee:
|
||||||
|
nonce: 670593955
|
||||||
|
payload:
|
||||||
|
permission: 1
|
||||||
|
room: 7ed9e067-ec37-4054-9fc2-b1bd890929bd
|
||||||
|
typ: add_member
|
||||||
|
user: 83ce46ced47ec0391c64846cbb6c507250ead4985b6a044d68751edc46015dd7
|
||||||
|
timestamp: 1724966284
|
||||||
|
user: 83ce46ced47ec0391c64846cbb6c507250ead4985b6a044d68751edc46015dd7
|
||||||
|
responses:
|
||||||
|
204:
|
||||||
|
description: Operation completed.
|
||||||
|
404:
|
||||||
|
description: |
|
||||||
|
Room does not exist or the user does not have permission for management.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ApiError'
|
||||||
|
|
||||||
components:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
ApiError:
|
ApiError:
|
||||||
|
@ -156,3 +203,11 @@ components:
|
||||||
type: string
|
type: string
|
||||||
message:
|
message:
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
RoomMetadata:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
attrs:
|
||||||
|
type: int64
|
||||||
|
|
|
@ -13,8 +13,8 @@ use axum::routing::{get, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use axum_extra::extract::WithRejection;
|
use axum_extra::extract::WithRejection;
|
||||||
use blah::types::{
|
use blah::types::{
|
||||||
ChatItem, ChatPayload, CreateRoomPayload, MemberPermission, RoomAttrs, ServerPermission,
|
ChatItem, ChatPayload, CreateRoomPayload, MemberPermission, RoomAdminPayload, RoomAttrs,
|
||||||
Signee, UserKey, WithSig,
|
ServerPermission, Signee, UserKey, WithSig,
|
||||||
};
|
};
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use ed25519_dalek::SIGNATURE_LENGTH;
|
use ed25519_dalek::SIGNATURE_LENGTH;
|
||||||
|
@ -153,10 +153,12 @@ async fn main_async(st: AppState) -> Result<()> {
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/room/create", post(room_create))
|
.route("/room/create", post(room_create))
|
||||||
|
.route("/room/:ruuid", get(room_get_metadata))
|
||||||
// NB. Sync with `feed_url` and `next_url` generation.
|
// NB. Sync with `feed_url` and `next_url` generation.
|
||||||
.route("/room/:ruuid/feed.json", get(room_get_feed))
|
.route("/room/:ruuid/feed.json", get(room_get_feed))
|
||||||
.route("/room/:ruuid/event", get(room_event))
|
.route("/room/:ruuid/event", get(room_event))
|
||||||
.route("/room/:ruuid/item", get(room_get_item).post(room_post_item))
|
.route("/room/:ruuid/item", get(room_get_item).post(room_post_item))
|
||||||
|
.route("/room/:ruuid/admin", post(room_admin))
|
||||||
.with_state(st.clone())
|
.with_state(st.clone())
|
||||||
// NB. This comes at last (outmost layer), so inner errors will still be wraped with
|
// NB. This comes at last (outmost layer), so inner errors will still be wraped with
|
||||||
// correct CORS headers.
|
// correct CORS headers.
|
||||||
|
@ -269,6 +271,7 @@ struct GetRoomItemParams {
|
||||||
deserialize_with = "serde_aux::field_attributes::deserialize_number_from_string"
|
deserialize_with = "serde_aux::field_attributes::deserialize_number_from_string"
|
||||||
)]
|
)]
|
||||||
before_id: u64,
|
before_id: u64,
|
||||||
|
page_len: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn room_get_item(
|
async fn room_get_item(
|
||||||
|
@ -284,6 +287,23 @@ async fn room_get_item(
|
||||||
Ok(Json((room_meta, items)))
|
Ok(Json((room_meta, items)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn room_get_metadata(
|
||||||
|
st: ArcState,
|
||||||
|
WithRejection(Path(ruuid), _): WithRejection<Path<Uuid>, ApiError>,
|
||||||
|
OptionalAuth(user): OptionalAuth,
|
||||||
|
) -> Result<Json<RoomMetadata>, ApiError> {
|
||||||
|
let (room_meta, _) = query_room_items(
|
||||||
|
&st,
|
||||||
|
ruuid,
|
||||||
|
user.as_ref(),
|
||||||
|
&GetRoomItemParams {
|
||||||
|
before_id: 0,
|
||||||
|
page_len: Some(0),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(Json(room_meta))
|
||||||
|
}
|
||||||
|
|
||||||
async fn room_get_feed(
|
async fn room_get_feed(
|
||||||
st: ArcState,
|
st: ArcState,
|
||||||
WithRejection(Path(ruuid), _): WithRejection<Path<Uuid>, ApiError>,
|
WithRejection(Path(ruuid), _): WithRejection<Path<Uuid>, ApiError>,
|
||||||
|
@ -312,9 +332,14 @@ async fn room_get_feed(
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let page_len = params
|
||||||
|
.page_len
|
||||||
|
.unwrap_or(st.config.server.max_page_len)
|
||||||
|
.min(st.config.server.max_page_len);
|
||||||
|
|
||||||
let base_url = &st.config.server.base_url;
|
let base_url = &st.config.server.base_url;
|
||||||
let feed_url = format!("{base_url}/room/{ruuid}/feed.json");
|
let feed_url = format!("{base_url}/room/{ruuid}/feed.json");
|
||||||
let next_url = (items.len() == st.config.server.max_page_len).then(|| {
|
let next_url = (items.len() == page_len).then(|| {
|
||||||
let last_id = &items.last().expect("page size is not 0").id;
|
let last_id = &items.last().expect("page size is not 0").id;
|
||||||
format!("{feed_url}?before_id={last_id}")
|
format!("{feed_url}?before_id={last_id}")
|
||||||
});
|
});
|
||||||
|
@ -418,6 +443,15 @@ fn query_room_items(
|
||||||
|
|
||||||
let room_meta = RoomMetadata { title, attrs };
|
let room_meta = RoomMetadata { title, attrs };
|
||||||
|
|
||||||
|
if params.page_len == Some(0) {
|
||||||
|
return Ok((room_meta, Vec::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let page_len = params
|
||||||
|
.page_len
|
||||||
|
.unwrap_or(st.config.server.max_page_len)
|
||||||
|
.min(st.config.server.max_page_len);
|
||||||
|
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
r"
|
r"
|
||||||
SELECT `cid`, `timestamp`, `nonce`, `sig`, `userkey`, `sig`, `rich_text`
|
SELECT `cid`, `timestamp`, `nonce`, `sig`, `userkey`, `sig`, `rich_text`
|
||||||
|
@ -434,7 +468,7 @@ fn query_room_items(
|
||||||
named_params! {
|
named_params! {
|
||||||
":rid": rid,
|
":rid": rid,
|
||||||
":before_cid": params.before_id,
|
":before_cid": params.before_id,
|
||||||
":limit": st.config.server.max_page_len,
|
":limit": page_len,
|
||||||
},
|
},
|
||||||
|row| {
|
|row| {
|
||||||
let cid = row.get::<_, u64>("cid")?;
|
let cid = row.get::<_, u64>("cid")?;
|
||||||
|
@ -584,3 +618,86 @@ async fn room_event(
|
||||||
let stream = futures_util::stream::iter(Some(Ok(first_event))).chain(stream);
|
let stream = futures_util::stream::iter(Some(Ok(first_event))).chain(stream);
|
||||||
Ok(sse::Sse::new(stream).keep_alive(sse::KeepAlive::default()))
|
Ok(sse::Sse::new(stream).keep_alive(sse::KeepAlive::default()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn room_admin(
|
||||||
|
st: ArcState,
|
||||||
|
Path(ruuid): Path<Uuid>,
|
||||||
|
SignedJson(op): SignedJson<RoomAdminPayload>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
if ruuid != *op.signee.payload.room() {
|
||||||
|
return Err(error_response!(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid_request",
|
||||||
|
"URI and payload room id mismatch",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let RoomAdminPayload::AddMember {
|
||||||
|
permission, user, ..
|
||||||
|
} = op.signee.payload;
|
||||||
|
if user != op.signee.user {
|
||||||
|
return Err(error_response!(
|
||||||
|
StatusCode::NOT_IMPLEMENTED,
|
||||||
|
"not_implemented",
|
||||||
|
"only self-adding is implemented yet",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if permission.is_empty() || !MemberPermission::MAX_SELF_ADD.contains(permission) {
|
||||||
|
return Err(error_response!(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"deserialization",
|
||||||
|
"invalid permission",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut conn = st.conn.lock().unwrap();
|
||||||
|
let txn = conn.transaction()?;
|
||||||
|
let Some(rid) = txn
|
||||||
|
.query_row(
|
||||||
|
r"
|
||||||
|
SELECT `rid`
|
||||||
|
FROM `room`
|
||||||
|
WHERE `ruuid` = :ruuid AND
|
||||||
|
(`room`.`attrs` & :joinable) = :joinable
|
||||||
|
",
|
||||||
|
named_params! {
|
||||||
|
":ruuid": ruuid,
|
||||||
|
":joinable": RoomAttrs::PUBLIC_JOINABLE,
|
||||||
|
},
|
||||||
|
|row| row.get::<_, u64>("rid"),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
else {
|
||||||
|
return Err(error_response!(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"permission_denied",
|
||||||
|
"room does not exists or user is not allowed to join this room",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
txn.execute(
|
||||||
|
r"
|
||||||
|
INSERT INTO `user` (`userkey`)
|
||||||
|
VALUES (?)
|
||||||
|
ON CONFLICT (`userkey`) DO NOTHING
|
||||||
|
",
|
||||||
|
params![user],
|
||||||
|
)?;
|
||||||
|
txn.execute(
|
||||||
|
r"
|
||||||
|
INSERT INTO `room_member` (`rid`, `uid`, `permission`)
|
||||||
|
SELECT :rid, `uid`, :perm
|
||||||
|
FROM `user`
|
||||||
|
WHERE `userkey` = :userkey
|
||||||
|
ON CONFLICT (`rid`, `uid`) DO UPDATE SET
|
||||||
|
`permission` = :perm
|
||||||
|
",
|
||||||
|
named_params! {
|
||||||
|
":rid": rid,
|
||||||
|
":userkey": user,
|
||||||
|
":perm": permission,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
txn.commit()?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
|
@ -63,6 +63,7 @@
|
||||||
pattern="https://.*"
|
pattern="https://.*"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<button id="join-room">join room</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="chat">chat:</label>
|
<label for="chat">chat:</label>
|
||||||
|
|
|
@ -3,6 +3,7 @@ const userPubkeyDisplay = document.querySelector('#user-pubkey');
|
||||||
const roomUrlInput = document.querySelector('#room-url');
|
const roomUrlInput = document.querySelector('#room-url');
|
||||||
const chatInput = document.querySelector('#chat');
|
const chatInput = document.querySelector('#chat');
|
||||||
const regenKeyBtn = document.querySelector('#regen-key');
|
const regenKeyBtn = document.querySelector('#regen-key');
|
||||||
|
const joinRoomBtn = document.querySelector('#join-room');
|
||||||
|
|
||||||
let roomUrl = '';
|
let roomUrl = '';
|
||||||
let roomUuid = null;
|
let roomUuid = null;
|
||||||
|
@ -19,6 +20,11 @@ function hexToBuf(hex) {
|
||||||
return new Uint8Array(hex.match(/[\da-f]{2}/gi).map(m => parseInt(m, 16)))
|
return new Uint8Array(hex.match(/[\da-f]{2}/gi).map(m => parseInt(m, 16)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getUserPubkey() {
|
||||||
|
if (keypair === null) throw new Error('no userkey');
|
||||||
|
return bufToHex(await crypto.subtle.exportKey('raw', keypair.publicKey));
|
||||||
|
}
|
||||||
|
|
||||||
function appendMsg(el) {
|
function appendMsg(el) {
|
||||||
msgFlow.append(el);
|
msgFlow.append(el);
|
||||||
msgFlow.scrollTo({
|
msgFlow.scrollTo({
|
||||||
|
@ -60,6 +66,7 @@ async function generateKeypair() {
|
||||||
log('generating keypair');
|
log('generating keypair');
|
||||||
regenKeyBtn.disabled = true;
|
regenKeyBtn.disabled = true;
|
||||||
chatInput.disabled = true;
|
chatInput.disabled = true;
|
||||||
|
joinRoomBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
keypair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']);
|
keypair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -79,6 +86,7 @@ async function generateKeypair() {
|
||||||
|
|
||||||
regenKeyBtn.disabled = false;
|
regenKeyBtn.disabled = false;
|
||||||
chatInput.disabled = false;
|
chatInput.disabled = false;
|
||||||
|
joinRoomBtn.disabled = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ser = (k) => crypto.subtle.exportKey('jwk', k);
|
const ser = (k) => crypto.subtle.exportKey('jwk', k);
|
||||||
|
@ -94,7 +102,6 @@ async function generateKeypair() {
|
||||||
|
|
||||||
async function showChatMsg(chat) {
|
async function showChatMsg(chat) {
|
||||||
let verifyRet = null;
|
let verifyRet = null;
|
||||||
crypto.subtle.exportKey('raw', keypair.publicKey)
|
|
||||||
try {
|
try {
|
||||||
const sortKeys = (obj) =>
|
const sortKeys = (obj) =>
|
||||||
Object.fromEntries(Object.entries(obj).sort((lhs, rhs) => lhs[0] > rhs[0]));
|
Object.fromEntries(Object.entries(obj).sort((lhs, rhs) => lhs[0] > rhs[0]));
|
||||||
|
@ -217,8 +224,44 @@ async function connectRoom(url) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function joinRoom() {
|
||||||
|
try {
|
||||||
|
joinRoomBtn.disabled = true;
|
||||||
|
await signAndPost(`${roomUrl}/admin`, {
|
||||||
|
// sorted fields.
|
||||||
|
permission: 1, // POST_CHAT
|
||||||
|
room: roomUuid,
|
||||||
|
typ: 'add_member',
|
||||||
|
user: await getUserPubkey(),
|
||||||
|
});
|
||||||
|
log('joined room');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
log(`failed to join room: ${e}`);
|
||||||
|
} finally {
|
||||||
|
joinRoomBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signAndPost(url, data) {
|
||||||
|
const signedPayload = await signData(data);
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
cache: 'no-cache',
|
||||||
|
body: signedPayload,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const errResp = await resp.json();
|
||||||
|
throw new Error(`status ${resp.status}: ${errResp.error.message}`);
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
async function signData(payload) {
|
async function signData(payload) {
|
||||||
const userKey = bufToHex(await crypto.subtle.exportKey('raw', keypair.publicKey));
|
const userKey = await getUserPubkey();
|
||||||
const nonceBuf = new Uint32Array(1);
|
const nonceBuf = new Uint32Array(1);
|
||||||
crypto.getRandomValues(nonceBuf);
|
crypto.getRandomValues(nonceBuf);
|
||||||
const timestamp = (Number(new Date()) / 1000) | 0;
|
const timestamp = (Number(new Date()) / 1000) | 0;
|
||||||
|
@ -248,24 +291,12 @@ async function postChat(text) {
|
||||||
} else {
|
} else {
|
||||||
richText = [text];
|
richText = [text];
|
||||||
}
|
}
|
||||||
const signedPayload = await signData({
|
await signAndPost(`${roomUrl}/item`, {
|
||||||
// sorted fields.
|
// sorted fields.
|
||||||
rich_text: richText,
|
rich_text: richText,
|
||||||
room: roomUuid,
|
room: roomUuid,
|
||||||
typ: 'chat',
|
typ: 'chat',
|
||||||
});
|
});
|
||||||
const resp = await fetch(`${roomUrl}/item`, {
|
|
||||||
method: 'POST',
|
|
||||||
cache: 'no-cache',
|
|
||||||
body: signedPayload,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
const errResp = await resp.json();
|
|
||||||
throw new Error(`status ${resp.status}: ${errResp.error.message}`);
|
|
||||||
}
|
|
||||||
chatInput.value = '';
|
chatInput.value = '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
@ -280,20 +311,21 @@ window.onload = async (_) => {
|
||||||
await generateKeypair();
|
await generateKeypair();
|
||||||
}
|
}
|
||||||
if (keypair !== null) {
|
if (keypair !== null) {
|
||||||
userPubkeyDisplay.value = bufToHex(await crypto.subtle.exportKey('raw', keypair.publicKey));
|
userPubkeyDisplay.value = await getUserPubkey();
|
||||||
}
|
}
|
||||||
connectRoom(roomUrlInput.value);
|
await connectRoom(roomUrlInput.value);
|
||||||
};
|
};
|
||||||
roomUrlInput.onchange = (e) => {
|
roomUrlInput.onchange = async (e) => {
|
||||||
connectRoom(e.target.value);
|
await connectRoom(e.target.value);
|
||||||
};
|
};
|
||||||
chatInput.onkeypress = (e) => {
|
chatInput.onkeypress = async (e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
chatInput.disabled = true;
|
await postChat(chatInput.value);
|
||||||
postChat(chatInput.value);
|
|
||||||
chatInput.disabled = false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
regenKeyBtn.onclick = (_) => {
|
regenKeyBtn.onclick = async (_) => {
|
||||||
generateKeypair();
|
await generateKeypair();
|
||||||
|
};
|
||||||
|
joinRoomBtn.onclick = async (_) => {
|
||||||
|
await joinRoom();
|
||||||
};
|
};
|
||||||
|
|
11
src/types.rs
11
src/types.rs
|
@ -337,6 +337,14 @@ pub enum RoomAdminPayload {
|
||||||
// TODO: CRUD
|
// TODO: CRUD
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl RoomAdminPayload {
|
||||||
|
pub fn room(&self) -> &Uuid {
|
||||||
|
match self {
|
||||||
|
RoomAdminPayload::AddMember { room, .. } => room,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bitflags! {
|
bitflags! {
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct ServerPermission: u64 {
|
pub struct ServerPermission: u64 {
|
||||||
|
@ -350,12 +358,15 @@ bitflags! {
|
||||||
const POST_CHAT = 1 << 0;
|
const POST_CHAT = 1 << 0;
|
||||||
const ADD_MEMBER = 1 << 1;
|
const ADD_MEMBER = 1 << 1;
|
||||||
|
|
||||||
|
const MAX_SELF_ADD = Self::POST_CHAT.bits();
|
||||||
|
|
||||||
const ALL = !0;
|
const ALL = !0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub struct RoomAttrs: u64 {
|
pub struct RoomAttrs: u64 {
|
||||||
const PUBLIC_READABLE = 1 << 0;
|
const PUBLIC_READABLE = 1 << 0;
|
||||||
|
const PUBLIC_JOINABLE = 1 << 1;
|
||||||
|
|
||||||
const _ = !0;
|
const _ = !0;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue