feature: avatar uploads

This commit is contained in:
Andy Burke 2025-08-12 16:00:36 -07:00
parent ffe0678e5b
commit 01768da647
7 changed files with 139 additions and 18 deletions

View file

@ -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

View file

@ -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
View file

@ -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",

View file

@ -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();
} }

View file

@ -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);

View file

@ -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">

View file

@ -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',