feature: avatar uploads
This commit is contained in:
parent
ffe0678e5b
commit
01768da647
7 changed files with 139 additions and 18 deletions
|
@ -14,12 +14,13 @@ feature discussions.
|
||||||
- [X] log in
|
- [X] log in
|
||||||
- [X] refactor login/sessions/totp
|
- [X] refactor login/sessions/totp
|
||||||
- [ ] media uploads
|
- [ ] media uploads
|
||||||
- [ ] local upload support (keep it simple for small instances)
|
- [X] local upload support (keep it simple for small instances)
|
||||||
- [ ] S3 support (then self-host with your friends: https://garagehq.deuxfleurs.fr/)
|
- [ ] S3 support (then self-host with your friends: https://garagehq.deuxfleurs.fr/)
|
||||||
|
- [ ] test mounting an s3 volume under /files and having no s3 support in the codebase
|
||||||
- [X] user profile page
|
- [X] user profile page
|
||||||
- [X] logout button
|
- [X] logout button
|
||||||
- [ ] profile editing
|
- [ ] profile editing
|
||||||
- [ ] avatar uploads
|
- [X] avatar uploads
|
||||||
- [X] chat rooms
|
- [X] chat rooms
|
||||||
- [X] chat messages
|
- [X] chat messages
|
||||||
- [ ] @-prefixing of users for notifications/highlighting
|
- [ ] @-prefixing of users for notifications/highlighting
|
||||||
|
|
|
@ -7,8 +7,8 @@
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"lint": "deno lint",
|
"lint": "deno lint",
|
||||||
"fmt": "deno fmt",
|
"fmt": "deno fmt",
|
||||||
"serve": "FSDB_ROOT=$PWD/.fsdb TRACE_ERROR_RESPONSES=true SERVERUS_TYPESCRIPT_IMPORT_LOGGING=true SERVERUS_PUT_PATHS_ALLOWED=./public/files SERVERUS_DELETE_PATHS_ALLOWED=./public/files deno --allow-env --allow-read --allow-write --allow-net @andyburke/serverus --root ./public --hostname 0.0.0.0",
|
"serve": "FSDB_ROOT=$PWD/.fsdb TRACE_ERROR_RESPONSES=true SERVERUS_TYPESCRIPT_IMPORT_LOGGING=true SERVERUS_PUT_PATHS_ALLOWED=./files SERVERUS_DELETE_PATHS_ALLOWED=./files deno --allow-env --allow-read --allow-write --allow-net @andyburke/serverus --root ./public --hostname 0.0.0.0",
|
||||||
"test": "DENO_ENV=test FSDB_ROOT=$PWD/tests/data/$(date --iso-8601=seconds) SERVERUS_ROOT=$PWD/public deno test --allow-env --allow-read --allow-write --allow-net --allow-import --trace-leaks --fail-fast tests/"
|
"test": "DENO_ENV=test FSDB_ROOT=$PWD/tests/data/$(date --iso-8601=seconds) SERVERUS_ROOT=$PWD/public SERVERUS_PUT_PATHS_ALLOWED=./files SERVERUS_DELETE_PATHS_ALLOWED=./files deno test --allow-env --allow-read --allow-write --allow-net --allow-import --trace-leaks --fail-fast tests/"
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"exclude": ["tests/data/"]
|
"exclude": ["tests/data/"]
|
||||||
|
@ -34,7 +34,7 @@
|
||||||
"imports": {
|
"imports": {
|
||||||
"@andyburke/fsdb": "jsr:@andyburke/fsdb@^1.0.2",
|
"@andyburke/fsdb": "jsr:@andyburke/fsdb@^1.0.2",
|
||||||
"@andyburke/lurid": "jsr:@andyburke/lurid@^0.2.0",
|
"@andyburke/lurid": "jsr:@andyburke/lurid@^0.2.0",
|
||||||
"@andyburke/serverus": "jsr:@andyburke/serverus@^0.12.2",
|
"@andyburke/serverus": "jsr:@andyburke/serverus@^0.12.5",
|
||||||
"@da/bcrypt": "jsr:@da/bcrypt@^1.0.1",
|
"@da/bcrypt": "jsr:@da/bcrypt@^1.0.1",
|
||||||
"@std/assert": "jsr:@std/assert@^1.0.13",
|
"@std/assert": "jsr:@std/assert@^1.0.13",
|
||||||
"@std/encoding": "jsr:@std/encoding@^1.0.10",
|
"@std/encoding": "jsr:@std/encoding@^1.0.10",
|
||||||
|
|
8
deno.lock
generated
8
deno.lock
generated
|
@ -3,7 +3,7 @@
|
||||||
"specifiers": {
|
"specifiers": {
|
||||||
"jsr:@andyburke/fsdb@^1.0.2": "1.0.2",
|
"jsr:@andyburke/fsdb@^1.0.2": "1.0.2",
|
||||||
"jsr:@andyburke/lurid@0.2": "0.2.0",
|
"jsr:@andyburke/lurid@0.2": "0.2.0",
|
||||||
"jsr:@andyburke/serverus@~0.12.2": "0.12.2",
|
"jsr:@andyburke/serverus@~0.12.5": "0.12.5",
|
||||||
"jsr:@da/bcrypt@*": "1.0.1",
|
"jsr:@da/bcrypt@*": "1.0.1",
|
||||||
"jsr:@da/bcrypt@^1.0.1": "1.0.1",
|
"jsr:@da/bcrypt@^1.0.1": "1.0.1",
|
||||||
"jsr:@std/assert@^1.0.13": "1.0.13",
|
"jsr:@std/assert@^1.0.13": "1.0.13",
|
||||||
|
@ -41,8 +41,8 @@
|
||||||
"jsr:@std/cli@^1.0.19"
|
"jsr:@std/cli@^1.0.19"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@andyburke/serverus@0.12.2": {
|
"@andyburke/serverus@0.12.5": {
|
||||||
"integrity": "17cf6d7cb58857c4bc34ee96aa718c05edf0fd4fe159afc5890253e50bd99c3a",
|
"integrity": "c6bf017e82f20625f9d29dacaa7e2b034e91c37b8171f6725fade4599db66864",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"jsr:@std/cli@^1.0.21",
|
"jsr:@std/cli@^1.0.21",
|
||||||
"jsr:@std/fmt@^1.0.6",
|
"jsr:@std/fmt@^1.0.6",
|
||||||
|
@ -131,7 +131,7 @@
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"jsr:@andyburke/fsdb@^1.0.2",
|
"jsr:@andyburke/fsdb@^1.0.2",
|
||||||
"jsr:@andyburke/lurid@0.2",
|
"jsr:@andyburke/lurid@0.2",
|
||||||
"jsr:@andyburke/serverus@~0.12.2",
|
"jsr:@andyburke/serverus@~0.12.5",
|
||||||
"jsr:@da/bcrypt@^1.0.1",
|
"jsr:@da/bcrypt@^1.0.1",
|
||||||
"jsr:@std/assert@^1.0.13",
|
"jsr:@std/assert@^1.0.13",
|
||||||
"jsr:@std/encoding@^1.0.10",
|
"jsr:@std/encoding@^1.0.10",
|
||||||
|
|
|
@ -18,6 +18,7 @@ export function load() {
|
||||||
const is_to_home_dir = meta.user?.id && path.toLowerCase().startsWith(`/files/users/${meta.user.id}/`);
|
const is_to_home_dir = meta.user?.id && path.toLowerCase().startsWith(`/files/users/${meta.user.id}/`);
|
||||||
|
|
||||||
const has_permission = is_to_files && (can_write_all_files || (can_write_own_files && is_to_home_dir));
|
const has_permission = is_to_files && (can_write_all_files || (can_write_own_files && is_to_home_dir));
|
||||||
|
|
||||||
if (!has_permission) {
|
if (!has_permission) {
|
||||||
return CANNED_RESPONSES.permission_denied();
|
return CANNED_RESPONSES.permission_denied();
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,8 @@ const api = {
|
||||||
if (options.json) {
|
if (options.json) {
|
||||||
headers["Content-Type"] = "application/json";
|
headers["Content-Type"] = "application/json";
|
||||||
fetch_options.body = JSON.stringify(options.json);
|
fetch_options.body = JSON.stringify(options.json);
|
||||||
|
} else if (options.body) {
|
||||||
|
fetch_options.body = options.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, fetch_options);
|
const response = await fetch(url, fetch_options);
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
|
const DEFAULT_AVATAR_URL = `//${window.location.host}/images/default_avatar.gif`;
|
||||||
|
|
||||||
new MutationObserver((mutations, observer) => {
|
new MutationObserver((mutations, observer) => {
|
||||||
mutations.forEach((mutation) => {
|
mutations.forEach((mutation) => {
|
||||||
const user_json = document.body.dataset.user;
|
const user_json = document.body.dataset.user;
|
||||||
|
@ -7,19 +9,48 @@
|
||||||
: {
|
: {
|
||||||
username: "",
|
username: "",
|
||||||
meta: {
|
meta: {
|
||||||
avatar: "/images/default_avatar.gif",
|
avatar: DEFAULT_AVATAR_URL,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const avatars = document.querySelectorAll("[data-bind-user_meta_avatar]");
|
console.dir({
|
||||||
for (const avatar of avatars) {
|
user_json,
|
||||||
const bound_to = avatar.dataset["bind-user_meta_avatar"] ?? "innerHTML";
|
user,
|
||||||
avatar[bound_to] = user.meta?.avatar ?? "/images/default_avatar.gif";
|
});
|
||||||
|
const ids = document.querySelectorAll("[data-bind__user_id]");
|
||||||
|
for (const id of ids) {
|
||||||
|
const bound_to =
|
||||||
|
typeof id.dataset["bind__user_id"] === "string" &&
|
||||||
|
id.dataset["bind__user_id"].length > 0
|
||||||
|
? id.dataset["bind__user_id"]
|
||||||
|
: "innerHTML";
|
||||||
|
avatar[bound_to] = user.id ?? "<unknown>";
|
||||||
}
|
}
|
||||||
|
|
||||||
const usernames = document.querySelectorAll("[data-bind-user_username]");
|
const avatars = document.querySelectorAll("[data-bind__user_meta_avatar]");
|
||||||
|
for (const avatar of avatars) {
|
||||||
|
console.dir({
|
||||||
|
avatar,
|
||||||
|
});
|
||||||
|
const bound_to =
|
||||||
|
typeof avatar.dataset["bind__user_meta_avatar"] === "string" &&
|
||||||
|
avatar.dataset["bind__user_meta_avatar"].length
|
||||||
|
? avatar.dataset["bind__user_meta_avatar"]
|
||||||
|
: "innerHTML";
|
||||||
|
avatar[bound_to] = user.meta?.avatar ?? DEFAULT_AVATAR_URL;
|
||||||
|
|
||||||
|
console.dir({
|
||||||
|
avatar,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const usernames = document.querySelectorAll("[data-bind__user_username]");
|
||||||
for (const username of usernames) {
|
for (const username of usernames) {
|
||||||
const bound_to = username.dataset["bind-user_username"] ?? "innerHTML";
|
const bound_to =
|
||||||
|
typeof username.dataset["bind__user_username"] === "string" &&
|
||||||
|
username.dataset["bind__user_username"].length > 0
|
||||||
|
? username.dataset["bind__user_username"]
|
||||||
|
: "innerHTML";
|
||||||
username[bound_to] = user.username;
|
username[bound_to] = user.username;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -32,7 +63,33 @@
|
||||||
.profile-container {
|
.profile-container {
|
||||||
margin: 1rem auto;
|
margin: 1rem auto;
|
||||||
max-width: 1024px;
|
max-width: 1024px;
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-container .avatar-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-container .avatar-container #user-avatar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-container .avatar-container input[type="file"] {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-container .avatar-container
|
||||||
</style>
|
</style>
|
||||||
<div id="user" class="tab">
|
<div id="user" class="tab">
|
||||||
<input
|
<input
|
||||||
|
@ -53,12 +110,68 @@
|
||||||
id="user-avatar"
|
id="user-avatar"
|
||||||
src="/images/default_avatar.gif"
|
src="/images/default_avatar.gif"
|
||||||
alt="User Avatar"
|
alt="User Avatar"
|
||||||
data-bind-user_meta_avatar="src"
|
data-bind__user_meta_avatar="src"
|
||||||
/>
|
/>
|
||||||
|
<input type="file" accept="image/*" name="avatar" />
|
||||||
|
<script>
|
||||||
|
const avatar_file_input = document.querySelector('input[name="avatar"]');
|
||||||
|
avatar_file_input.addEventListener("change", async (event) => {
|
||||||
|
const user_json = document.body.dataset.user;
|
||||||
|
if (!user_json) {
|
||||||
|
return alert("You must be logged in.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = JSON.parse(user_json);
|
||||||
|
const avatar = avatar_file_input.files[0];
|
||||||
|
|
||||||
|
if (!avatar || !avatar.type || !avatar.type.includes("image")) {
|
||||||
|
return alert("You must select a valid image to upload as your avatar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: actually enforce this on the upload in serverus somehow
|
||||||
|
if (avatar.size > 512_000) {
|
||||||
|
return alert("512K is the largest allowed avatar size.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("file", avatar, encodeURIComponent(avatar.name));
|
||||||
|
|
||||||
|
const avatar_path = `/files/users/${user.id}/avatars/${avatar.name}`;
|
||||||
|
|
||||||
|
const avatar_upload_response = await api.fetch(avatar_path, {
|
||||||
|
method: "PUT",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!avatar_upload_response.ok) {
|
||||||
|
const error = await avatar_upload_response.json();
|
||||||
|
return alert(error?.error?.message ?? "Unknown error.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated_user = { ...user };
|
||||||
|
updated_user.meta = updated_user.meta ?? {};
|
||||||
|
updated_user.meta.avatar = `//${window.location.host}${avatar_path}`;
|
||||||
|
|
||||||
|
const saved_user_response = await api.fetch(`/api/users/${user.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
json: updated_user,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!saved_user_response.ok) {
|
||||||
|
const error = await avatar_upload_response.json();
|
||||||
|
return alert(error?.error?.message ?? "Unknown error.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved_user = await saved_user_response.json();
|
||||||
|
|
||||||
|
document.body.dataset.user = JSON.stringify(saved_user);
|
||||||
|
document.body.dataset.perms = saved_user.permissions.join(":");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="username-container">
|
<div class="username-container">
|
||||||
<span class="username" data-bind-user_username></span>
|
<span class="username" data-bind__user_username></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form data-smart="true" data-method="DELETE" action="/api/auth">
|
<form data-smart="true" data-method="DELETE" action="/api/auth">
|
||||||
|
|
|
@ -23,6 +23,10 @@ Deno.test({
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
let test_server_info: EPHEMERAL_SERVER | null = null;
|
let test_server_info: EPHEMERAL_SERVER | null = null;
|
||||||
try {
|
try {
|
||||||
|
console.dir({
|
||||||
|
SERVERUS_PUT_PATHS_ALLOWED: Deno.env.get('SERVERUS_PUT_PATHS_ALLOWED')
|
||||||
|
});
|
||||||
|
|
||||||
test_server_info = await get_ephemeral_listen_server();
|
test_server_info = await get_ephemeral_listen_server();
|
||||||
const client: API_CLIENT = api({
|
const client: API_CLIENT = api({
|
||||||
prefix: '/api',
|
prefix: '/api',
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue