Allow editing metadata

This commit is contained in:
Kieran 2022-03-15 10:39:36 +00:00
parent 20d32ad6d3
commit 285899b742
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
11 changed files with 99 additions and 22 deletions

View File

@ -194,8 +194,7 @@ namespace VoidCat.Controllers
var gid = id.FromBase58Guid(); var gid = id.FromBase58Guid();
var meta = await _metadata.Get<SecretVoidFileMeta>(gid); var meta = await _metadata.Get<SecretVoidFileMeta>(gid);
if (meta == default) return NotFound(); if (meta == default) return NotFound();
if (!meta.CanEdit(req.EditSecret, HttpContext)) return Unauthorized();
if (req.EditSecret != meta.EditSecret) return Unauthorized();
if (req.Strike != default) if (req.Strike != default)
{ {
@ -207,6 +206,28 @@ namespace VoidCat.Controllers
await _paywall.Set(gid, new NoPaywallConfig()); await _paywall.Set(gid, new NoPaywallConfig());
return Ok(); return Ok();
} }
/// <summary>
/// Update metadata about file
/// </summary>
/// <param name="id">Id of file to edit</param>
/// <param name="fileMeta">New metadata to update</param>
/// <returns></returns>
/// <remarks>
/// You can only change `Name`, `Description` and `MimeType`
/// </remarks>
[HttpPost]
[Route("{id}/meta")]
public async Task<IActionResult> UpdateFileMeta([FromRoute] string id, [FromBody] SecretVoidFileMeta fileMeta)
{
var gid = id.FromBase58Guid();
var meta = await _metadata.Get<SecretVoidFileMeta>(gid);
if (meta == default) return NotFound();
if (!meta.CanEdit(fileMeta.EditSecret, HttpContext)) return Unauthorized();
await _metadata.Update(gid, fileMeta);
return Ok();
}
} }
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]

View File

@ -61,6 +61,12 @@ public static class Extensions
return !string.IsNullOrEmpty(h.Value.ToString()) ? h.Value.ToString() : default; return !string.IsNullOrEmpty(h.Value.ToString()) ? h.Value.ToString() : default;
} }
public static bool CanEdit(this SecretVoidFileMeta file, Guid? editSecret, HttpContext context)
{
return file.EditSecret == editSecret
|| file.Uploader == context.GetUserId();
}
public static string ToHex(this byte[] data) public static string ToHex(this byte[] data)
{ {
return BitConverter.ToString(data).Replace("-", string.Empty).ToLower(); return BitConverter.ToString(data).Replace("-", string.Empty).ToLower();

View File

@ -26,7 +26,7 @@ public record VoidFileMeta : IVoidFileMeta
/// <summary> /// <summary>
/// Filename /// Filename
/// </summary> /// </summary>
public string? Name { get; init; } public string? Name { get; set; }
/// <summary> /// <summary>
/// Size of the file in storage /// Size of the file in storage
@ -41,12 +41,12 @@ public record VoidFileMeta : IVoidFileMeta
/// <summary> /// <summary>
/// Description about the file /// Description about the file
/// </summary> /// </summary>
public string? Description { get; init; } public string? Description { get; set; }
/// <summary> /// <summary>
/// The content type of the file /// The content type of the file
/// </summary> /// </summary>
public string? MimeType { get; init; } public string? MimeType { get; set; }
/// <summary> /// <summary>
/// SHA-256 hash of the file /// SHA-256 hash of the file

View File

@ -5,4 +5,5 @@ namespace VoidCat.Services.Abstractions;
public interface IFileMetadataStore : IPublicPrivateStore<VoidFileMeta, SecretVoidFileMeta> public interface IFileMetadataStore : IPublicPrivateStore<VoidFileMeta, SecretVoidFileMeta>
{ {
ValueTask<TMeta?> Get<TMeta>(Guid id) where TMeta : VoidFileMeta; ValueTask<TMeta?> Get<TMeta>(Guid id) where TMeta : VoidFileMeta;
ValueTask Update<TMeta>(Guid id, TMeta meta) where TMeta : VoidFileMeta;
} }

View File

@ -1,6 +1,5 @@
using Newtonsoft.Json; using Newtonsoft.Json;
using VoidCat.Model; using VoidCat.Model;
using VoidCat.Model.Exceptions;
using VoidCat.Services.Abstractions; using VoidCat.Services.Abstractions;
namespace VoidCat.Services.Files; namespace VoidCat.Services.Files;
@ -28,6 +27,18 @@ public class LocalDiskFileMetadataStore : IFileMetadataStore
return GetMeta<TMeta>(id); return GetMeta<TMeta>(id);
} }
public async ValueTask Update<TMeta>(Guid id, TMeta meta) where TMeta : VoidFileMeta
{
var oldMeta = await GetMeta<SecretVoidFileMeta>(id);
if (oldMeta == default) return;
oldMeta.Description = meta.Description ?? oldMeta.Description;
oldMeta.Name = meta.Name ?? oldMeta.Name;
oldMeta.MimeType = meta.MimeType ?? oldMeta.MimeType;
await Set(id, oldMeta);
}
public ValueTask<VoidFileMeta?> Get(Guid id) public ValueTask<VoidFileMeta?> Get(Guid id)
{ {
return GetMeta<VoidFileMeta>(id); return GetMeta<VoidFileMeta>(id);

View File

@ -25,6 +25,18 @@ public class S3FileMetadataStore : IFileMetadataStore
return GetMeta<TMeta>(id); return GetMeta<TMeta>(id);
} }
public async ValueTask Update<TMeta>(Guid id, TMeta meta) where TMeta : VoidFileMeta
{
var oldMeta = await GetMeta<SecretVoidFileMeta>(id);
if (oldMeta == default) return;
oldMeta.Description = meta.Description ?? oldMeta.Description;
oldMeta.Name = meta.Name ?? oldMeta.Name;
oldMeta.MimeType = meta.MimeType ?? oldMeta.MimeType;
await Set(id, oldMeta);
}
public ValueTask<VoidFileMeta?> Get(Guid id) public ValueTask<VoidFileMeta?> Get(Guid id)
{ {
return GetMeta<VoidFileMeta>(id); return GetMeta<VoidFileMeta>(id);

View File

@ -41,7 +41,8 @@ export function useApi() {
updateUser: (u) => getJson("POST", `/user/${u.id}`, u, auth), updateUser: (u) => getJson("POST", `/user/${u.id}`, u, auth),
listUserFiles: (uid, pageReq) => getJson("POST", `/user/${uid}/files`, pageReq, auth), listUserFiles: (uid, pageReq) => getJson("POST", `/user/${uid}/files`, pageReq, auth),
submitVerifyCode: (uid, code) => getJson("POST", `/user/${uid}/verify`, code, auth), submitVerifyCode: (uid, code) => getJson("POST", `/user/${uid}/verify`, code, auth),
sendNewCode: (uid) => getJson("GET", `/user/${uid}/verify`, undefined, auth) sendNewCode: (uid) => getJson("GET", `/user/${uid}/verify`, undefined, auth),
updateMetadata: (id, meta) => getJson("POST", `/upload/${id}/meta`, meta, auth)
} }
}; };
} }

View File

@ -5,6 +5,7 @@ import {NoPaywallConfig} from "./NoPaywallConfig";
import {useApi} from "./Api"; import {useApi} from "./Api";
import "./FileEdit.css"; import "./FileEdit.css";
import {useSelector} from "react-redux"; import {useSelector} from "react-redux";
import {VoidButton} from "./VoidButton";
export function FileEdit(props) { export function FileEdit(props) {
const {Api} = useApi(); const {Api} = useApi();
@ -25,6 +26,15 @@ export function FileEdit(props) {
return req.ok; return req.ok;
} }
async function saveMeta() {
let meta = {
name,
description,
editSecret: privateFile?.metadata?.editSecret
};
await Api.updateMetadata(file.id, meta);
}
function renderPaywallConfig() { function renderPaywallConfig() {
switch (paywall) { switch (paywall) {
case 0: { case 0: {
@ -47,7 +57,7 @@ export function FileEdit(props) {
<dt>Description:</dt> <dt>Description:</dt>
<dd><input type="text" value={description} onChange={(e) => setDescription(e.target.value)}/></dd> <dd><input type="text" value={description} onChange={(e) => setDescription(e.target.value)}/></dd>
</dl> </dl>
<VoidButton onClick={(e) => saveMeta()} options={{showSuccess: true}}>Save</VoidButton>
</div> </div>
<div className="flx-1"> <div className="flx-1">
<h3>Paywall Config</h3> <h3>Paywall Config</h3>

View File

@ -8,14 +8,12 @@ import {logout, setProfile as setGlobalProfile} from "./LoginState";
import {DigestAlgo} from "./FileUpload"; import {DigestAlgo} from "./FileUpload";
import {buf2hex, hasFlag} from "./Util"; import {buf2hex, hasFlag} from "./Util";
import moment from "moment"; import moment from "moment";
import FeatherIcon from "feather-icons-react";
import {FileList} from "./FileList"; import {FileList} from "./FileList";
import {VoidButton} from "./VoidButton"; import {VoidButton} from "./VoidButton";
export function Profile() { export function Profile() {
const [profile, setProfile] = useState(); const [profile, setProfile] = useState();
const [noProfile, setNoProfile] = useState(false); const [noProfile, setNoProfile] = useState(false);
const [saved, setSaved] = useState(false);
const [emailCode, setEmailCode] = useState(""); const [emailCode, setEmailCode] = useState("");
const [emailCodeError, setEmailCodeError] = useState(""); const [emailCodeError, setEmailCodeError] = useState("");
const [newCodeSent, setNewCodeSent] = useState(false); const [newCodeSent, setNewCodeSent] = useState(false);
@ -103,7 +101,6 @@ export function Profile() {
if (r.ok) { if (r.ok) {
// saved // saved
dispatch(setGlobalProfile(profile)); dispatch(setGlobalProfile(profile));
setSaved(true);
} }
} }
@ -160,10 +157,7 @@ export function Profile() {
</dl> </dl>
<div className="flex flex-center"> <div className="flex flex-center">
<div> <div>
<VoidButton onClick={saveUser}>Save</VoidButton> <VoidButton onClick={saveUser} options={{showSuccess: true}}>Save</VoidButton>
</div>
<div>
{saved ? <FeatherIcon icon="check-circle"/> : null}
</div> </div>
<div> <div>
<VoidButton onClick={() => dispatch(logout())}>Logout</VoidButton> <VoidButton onClick={() => dispatch(logout())}>Logout</VoidButton>
@ -177,12 +171,6 @@ export function Profile() {
loadProfile(); loadProfile();
}, []); }, []);
useEffect(() => {
if (saved === true) {
setTimeout(() => setSaved(false), 1000);
}
}, [saved]);
if (profile) { if (profile) {
let avatarUrl = profile.avatar ?? DefaultAvatar; let avatarUrl = profile.avatar ?? DefaultAvatar;
if (!avatarUrl.startsWith("http")) { if (!avatarUrl.startsWith("http")) {

View File

@ -1,4 +1,13 @@
import {useEffect, useState} from "react";
import FeatherIcon from "feather-icons-react";
export function VoidButton(props) { export function VoidButton(props) {
const options = {
showSuccess: false,
...props.options
};
const [success, setSuccess] = useState(false);
async function handleClick(e) { async function handleClick(e) {
if (e.target.classList.contains("disabled")) return; if (e.target.classList.contains("disabled")) return;
e.target.classList.add("disabled"); e.target.classList.add("disabled");
@ -9,10 +18,24 @@ export function VoidButton(props) {
if (typeof ret === "object" && typeof ret.then === "function") { if (typeof ret === "object" && typeof ret.then === "function") {
await ret; await ret;
} }
setSuccess(options.showSuccess);
} }
e.target.classList.remove("disabled"); e.target.classList.remove("disabled");
} }
return <div className="btn" onClick={handleClick}>{props.children}</div>; useEffect(() => {
if (success === true) {
setTimeout(() => setSuccess(false), 1000);
}
}, [success]);
return (
<div className="flex-inline flex-center">
<div>
<div className="btn" onClick={handleClick}>{props.children}</div>
</div>
{success ? <div><FeatherIcon icon="check-circle"/></div> : null}
</div>
);
} }

View File

@ -40,6 +40,10 @@ a:hover {
display: flex; display: flex;
} }
.flex-inline {
display: inline-flex;
}
.flx-1 { .flx-1 {
flex: 1; flex: 1;
} }