Added admin page and more authentication improvements with latest handy-httpd version.
This commit is contained in:
parent
859f17ccc5
commit
f3fe5e9671
|
@ -5,17 +5,18 @@
|
||||||
"copyright": "Copyright © 2023, Andrew Lalis",
|
"copyright": "Copyright © 2023, Andrew Lalis",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"botan": "~>1.13.5",
|
"botan": "~>1.13.5",
|
||||||
"d-properties": "~>1.0.4",
|
"d-properties": "~>1.0.5",
|
||||||
"d2sqlite3": "~>1.0.0",
|
"d2sqlite3": "~>1.0.0",
|
||||||
"handy-httpd": "~>7.9.3",
|
"handy-httpd": "~>7.10.4",
|
||||||
"jwt": "~>0.4.0",
|
"jwt": "~>0.4.0",
|
||||||
"resusage": "~>0.3.2",
|
"resusage": "~>0.3.2",
|
||||||
"slf4d": "~>2.4.2"
|
"slf4d": "~>2.4.3"
|
||||||
},
|
},
|
||||||
"description": "API for the litelist application.",
|
"description": "API for the litelist application.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"name": "litelist-api",
|
"name": "litelist-api",
|
||||||
"subConfigurations": {
|
"subConfigurations": {
|
||||||
"d2sqlite3": "all-included"
|
"d2sqlite3": "all-included"
|
||||||
}
|
},
|
||||||
|
"buildRequirements": ["allowWarnings"]
|
||||||
}
|
}
|
|
@ -3,14 +3,14 @@
|
||||||
"versions": {
|
"versions": {
|
||||||
"botan": "1.13.5",
|
"botan": "1.13.5",
|
||||||
"botan-math": "1.0.4",
|
"botan-math": "1.0.4",
|
||||||
"d-properties": "1.0.4",
|
"d-properties": "1.0.5",
|
||||||
"d2sqlite3": "1.0.0",
|
"d2sqlite3": "1.0.0",
|
||||||
"handy-httpd": "7.9.3",
|
"handy-httpd": "7.10.4",
|
||||||
"httparsed": "1.2.1",
|
"httparsed": "1.2.1",
|
||||||
"jwt": "0.4.0",
|
"jwt": "0.4.0",
|
||||||
"memutils": "1.0.9",
|
"memutils": "1.0.9",
|
||||||
"resusage": "0.3.2",
|
"resusage": "0.3.2",
|
||||||
"slf4d": "2.4.2",
|
"slf4d": "2.4.3",
|
||||||
"streams": "3.5.0"
|
"streams": "3.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import slf4d.default_provider;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
auto provider = new shared DefaultProvider(true, Levels.INFO);
|
auto provider = new shared DefaultProvider(true, Levels.INFO);
|
||||||
// provider.getLoggerFactory().setModuleLevelPrefix("handy_httpd", Levels.WARN);
|
// provider.getLoggerFactory().setModuleLevelPrefix("handy_httpd", Levels.DEBUG);
|
||||||
configureLoggingProvider(provider);
|
configureLoggingProvider(provider);
|
||||||
|
|
||||||
HttpServer server = initServer();
|
HttpServer server = initServer();
|
||||||
|
@ -21,9 +21,12 @@ private HttpServer initServer() {
|
||||||
import d_properties;
|
import d_properties;
|
||||||
import endpoints.auth;
|
import endpoints.auth;
|
||||||
import endpoints.lists;
|
import endpoints.lists;
|
||||||
|
import endpoints.admin;
|
||||||
import std.file;
|
import std.file;
|
||||||
import std.conv;
|
import std.conv;
|
||||||
|
|
||||||
|
import auth : TokenFilter, AdminFilter, loadTokenSecret;
|
||||||
|
|
||||||
ServerConfig config = ServerConfig.defaultValues();
|
ServerConfig config = ServerConfig.defaultValues();
|
||||||
config.enableWebSockets = false;
|
config.enableWebSockets = false;
|
||||||
config.workerPoolSize = 3;
|
config.workerPoolSize = 3;
|
||||||
|
@ -56,28 +59,42 @@ private HttpServer initServer() {
|
||||||
|
|
||||||
immutable string API_PATH = "/api";
|
immutable string API_PATH = "/api";
|
||||||
|
|
||||||
auto mainHandler = new PathDelegatingHandler();
|
PathDelegatingHandler mainHandler = new PathDelegatingHandler();
|
||||||
mainHandler.addMapping(Method.GET, API_PATH ~ "/status", &handleStatus);
|
mainHandler.addMapping(Method.GET, API_PATH ~ "/status", &handleStatus);
|
||||||
|
|
||||||
auto optionsHandler = toHandler((ref HttpRequestContext ctx) {
|
|
||||||
ctx.response.setStatus(HttpStatus.OK);
|
|
||||||
});
|
|
||||||
|
|
||||||
mainHandler.addMapping(Method.POST, API_PATH ~ "/register", &createNewUser);
|
mainHandler.addMapping(Method.POST, API_PATH ~ "/register", &createNewUser);
|
||||||
mainHandler.addMapping(Method.POST, API_PATH ~ "/login", &handleLogin);
|
mainHandler.addMapping(Method.POST, API_PATH ~ "/login", &handleLogin);
|
||||||
mainHandler.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser);
|
// mainHandler.addMapping(Method.GET, API_PATH ~ "/shutdown", (ref HttpRequestContext ctx) {
|
||||||
mainHandler.addMapping(Method.DELETE, API_PATH ~ "/me", &deleteMyUser);
|
// ctx.response.writeBodyString("Shutting down!");
|
||||||
mainHandler.addMapping(Method.GET, API_PATH ~ "/renew-token", &renewToken);
|
// ctx.server.stop();
|
||||||
|
// });
|
||||||
mainHandler.addMapping(Method.GET, API_PATH ~ "/lists", &getNoteLists);
|
|
||||||
mainHandler.addMapping(Method.POST, API_PATH ~ "/lists", &createNoteList);
|
|
||||||
mainHandler.addMapping(Method.GET, API_PATH ~ "/lists/{id}", &getNoteList);
|
|
||||||
mainHandler.addMapping(Method.DELETE, API_PATH ~ "/lists/{id}", &deleteNoteList);
|
|
||||||
mainHandler.addMapping(Method.POST, API_PATH ~ "/lists/{listId}/notes", &createNote);
|
|
||||||
mainHandler.addMapping(Method.DELETE, API_PATH ~ "/lists/{listId}/notes/{noteId}", &deleteNote);
|
|
||||||
|
|
||||||
|
HttpRequestHandler optionsHandler = toHandler((ref HttpRequestContext ctx) {
|
||||||
|
ctx.response.setStatus(HttpStatus.OK);
|
||||||
|
});
|
||||||
mainHandler.addMapping(Method.OPTIONS, API_PATH ~ "/**", optionsHandler);
|
mainHandler.addMapping(Method.OPTIONS, API_PATH ~ "/**", optionsHandler);
|
||||||
|
|
||||||
|
// Separate handler for authenticated paths, protected by a TokenFilter.
|
||||||
|
PathDelegatingHandler authHandler = new PathDelegatingHandler();
|
||||||
|
authHandler.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser);
|
||||||
|
authHandler.addMapping(Method.DELETE, API_PATH ~ "/me", &deleteMyUser);
|
||||||
|
authHandler.addMapping(Method.GET, API_PATH ~ "/renew-token", &renewToken);
|
||||||
|
|
||||||
|
authHandler.addMapping(Method.GET, API_PATH ~ "/lists", &getNoteLists);
|
||||||
|
authHandler.addMapping(Method.POST, API_PATH ~ "/lists", &createNoteList);
|
||||||
|
authHandler.addMapping(Method.GET, API_PATH ~ "/lists/{id}", &getNoteList);
|
||||||
|
authHandler.addMapping(Method.DELETE, API_PATH ~ "/lists/{id}", &deleteNoteList);
|
||||||
|
authHandler.addMapping(Method.POST, API_PATH ~ "/lists/{listId}/notes", &createNote);
|
||||||
|
authHandler.addMapping(Method.DELETE, API_PATH ~ "/lists/{listId}/notes/{noteId}", &deleteNote);
|
||||||
|
HttpRequestFilter tokenFilter = new TokenFilter(loadTokenSecret());
|
||||||
|
HttpRequestFilter adminFilter = new AdminFilter();
|
||||||
|
|
||||||
|
// Separate handler for admin paths, protected by an AdminFilter.
|
||||||
|
PathDelegatingHandler adminHandler = new PathDelegatingHandler();
|
||||||
|
adminHandler.addMapping(Method.GET, API_PATH ~ "/admin/users", &getAllUsers);
|
||||||
|
mainHandler.addMapping(API_PATH ~ "/admin/**", new FilteredRequestHandler(adminHandler, [tokenFilter, adminFilter]));
|
||||||
|
|
||||||
|
mainHandler.addMapping(API_PATH ~ "/**", new FilteredRequestHandler(authHandler, [tokenFilter]));
|
||||||
|
|
||||||
return new HttpServer(mainHandler, config);
|
return new HttpServer(mainHandler, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,8 +7,12 @@ import handy_httpd;
|
||||||
import handy_httpd.handlers.filtered_handler;
|
import handy_httpd.handlers.filtered_handler;
|
||||||
import slf4d;
|
import slf4d;
|
||||||
|
|
||||||
|
import std.typecons;
|
||||||
|
|
||||||
import data.user;
|
import data.user;
|
||||||
|
|
||||||
|
immutable string AUTH_METADATA_KEY = "AuthContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a new access token for an authenticated user.
|
* Generates a new access token for an authenticated user.
|
||||||
* Params:
|
* Params:
|
||||||
|
@ -25,7 +29,7 @@ string generateToken(in User user, in string secret) {
|
||||||
token.claims.sub(user.username);
|
token.claims.sub(user.username);
|
||||||
token.claims.exp(Clock.currTime.toUnixTime() + 5000);
|
token.claims.exp(Clock.currTime.toUnixTime() + 5000);
|
||||||
token.claims.iss("litelist-api");
|
token.claims.iss("litelist-api");
|
||||||
return token.encode("supersecret");// TODO: Extract secret.
|
return token.encode(secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendUnauthenticatedResponse(ref HttpResponse resp) {
|
void sendUnauthenticatedResponse(ref HttpResponse resp) {
|
||||||
|
@ -46,39 +50,14 @@ string loadTokenSecret() {
|
||||||
return "supersecret";
|
return "supersecret";
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AuthContext {
|
class AuthContext {
|
||||||
string token;
|
string token;
|
||||||
User user;
|
User user;
|
||||||
|
|
||||||
|
this(string token, User user) {
|
||||||
|
this.token = token;
|
||||||
|
this.user = user;
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuthContextHolder {
|
|
||||||
private static AuthContextHolder instance;
|
|
||||||
|
|
||||||
static getInstance() {
|
|
||||||
if (!instance) instance = new AuthContextHolder();
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
static reset() {
|
|
||||||
auto i = getInstance();
|
|
||||||
i.authenticated = false;
|
|
||||||
i.context = AuthContext.init;
|
|
||||||
}
|
|
||||||
|
|
||||||
static setContext(string token, User user) {
|
|
||||||
auto i = getInstance();
|
|
||||||
i.authenticated = true;
|
|
||||||
i.context = AuthContext(token, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
static AuthContext getOrThrow() {
|
|
||||||
auto i = getInstance();
|
|
||||||
if (!i.authenticated) throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "No authentication context.");
|
|
||||||
return i.context;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool authenticated;
|
|
||||||
private AuthContext context;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -88,46 +67,46 @@ class AuthContextHolder {
|
||||||
* Params:
|
* Params:
|
||||||
* ctx = The request context to validate.
|
* ctx = The request context to validate.
|
||||||
* secret = The secret key that should have been used to sign the token.
|
* secret = The secret key that should have been used to sign the token.
|
||||||
* Returns: True if the user is authenticated, or false otherwise.
|
* Returns: The AuthContext if authentication is successful, or null otherwise.
|
||||||
*/
|
*/
|
||||||
bool validateAuthenticatedRequest(ref HttpRequestContext ctx, in string secret) {
|
Nullable!AuthContext validateAuthenticatedRequest(ref HttpRequestContext ctx, in string secret) {
|
||||||
import jwt.jwt : verify, Token;
|
import jwt.jwt : verify, Token;
|
||||||
import jwt.algorithms : JWTAlgorithm;
|
import jwt.algorithms : JWTAlgorithm;
|
||||||
import std.typecons;
|
import std.typecons;
|
||||||
|
|
||||||
immutable HEADER_NAME = "Authorization";
|
immutable HEADER_NAME = "Authorization";
|
||||||
AuthContextHolder.reset();
|
|
||||||
if (!ctx.request.hasHeader(HEADER_NAME)) {
|
if (!ctx.request.hasHeader(HEADER_NAME)) {
|
||||||
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
|
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
|
||||||
ctx.response.writeBodyString("Missing Authorization header.");
|
ctx.response.writeBodyString("Missing Authorization header.");
|
||||||
return false;
|
return Nullable!AuthContext.init;
|
||||||
}
|
}
|
||||||
string authHeader = ctx.request.getHeader(HEADER_NAME);
|
string authHeader = ctx.request.getHeader(HEADER_NAME);
|
||||||
if (authHeader.length < 7 || authHeader[0 .. 7] != "Bearer ") {
|
if (authHeader.length < 7 || authHeader[0 .. 7] != "Bearer ") {
|
||||||
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
|
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
|
||||||
ctx.response.writeBodyString("Invalid bearer token authorization header.");
|
ctx.response.writeBodyString("Invalid bearer token authorization header.");
|
||||||
return false;
|
return Nullable!AuthContext.init;
|
||||||
}
|
}
|
||||||
|
|
||||||
string rawToken = authHeader[7 .. $];
|
string rawToken = authHeader[7 .. $];
|
||||||
string username;
|
string username;
|
||||||
try {
|
try {
|
||||||
Token token = verify(rawToken, "supersecret", [JWTAlgorithm.HS512]);
|
Token token = verify(rawToken, secret, [JWTAlgorithm.HS512]);
|
||||||
username = token.claims.sub;
|
username = token.claims.sub;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
warn("Failed to verify user token.", e);
|
warn("Failed to verify user token.", e);
|
||||||
throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid token.");
|
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
|
||||||
|
ctx.response.writeBodyString("Invalid or malformed token.");
|
||||||
|
return Nullable!AuthContext.init;
|
||||||
}
|
}
|
||||||
|
|
||||||
Nullable!User user = userDataSource.getUser(username);
|
Nullable!User user = userDataSource.getUser(username);
|
||||||
if (user.isNull) {
|
if (user.isNull) {
|
||||||
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
|
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
|
||||||
ctx.response.writeBodyString("User does not exist.");
|
ctx.response.writeBodyString("User does not exist.");
|
||||||
return false;
|
return Nullable!AuthContext.init;
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthContextHolder.setContext(rawToken, user.get);
|
return nullable(new AuthContext(rawToken, user.get));
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class TokenFilter : HttpRequestFilter {
|
class TokenFilter : HttpRequestFilter {
|
||||||
|
@ -138,6 +117,30 @@ class TokenFilter : HttpRequestFilter {
|
||||||
}
|
}
|
||||||
|
|
||||||
void apply(ref HttpRequestContext ctx, FilterChain filterChain) {
|
void apply(ref HttpRequestContext ctx, FilterChain filterChain) {
|
||||||
if (validateAuthenticatedRequest(ctx, this.secret)) filterChain.doFilter(ctx);
|
Nullable!AuthContext optionalAuth = validateAuthenticatedRequest(ctx, this.secret);
|
||||||
|
if (!optionalAuth.isNull) {
|
||||||
|
ctx.metadata[AUTH_METADATA_KEY] = optionalAuth.get();
|
||||||
|
filterChain.doFilter(ctx); // Only continue the filter chain if we're authenticated.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminFilter : HttpRequestFilter {
|
||||||
|
void apply(ref HttpRequestContext ctx, FilterChain filterChain) {
|
||||||
|
AuthContext authCtx = getAuthContextOrThrow(ctx);
|
||||||
|
if (authCtx.user.admin) {
|
||||||
|
filterChain.doFilter(ctx);
|
||||||
|
} else {
|
||||||
|
ctx.response.setStatus(HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthContext getAuthContextOrThrow(ref HttpRequestContext ctx) {
|
||||||
|
if (AUTH_METADATA_KEY in ctx.metadata) {
|
||||||
|
if (auto authCtx = cast(AuthContext) ctx.metadata[AUTH_METADATA_KEY]) {
|
||||||
|
return authCtx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated.");
|
||||||
|
}
|
||||||
|
|
|
@ -102,4 +102,9 @@ class SqliteNoteListDataSource : NoteListDataSource {
|
||||||
db.commit();
|
db.commit();
|
||||||
return NoteList(id, newData.name, newData.ordinality, newData.description, []);
|
return NoteList(id, newData.name, newData.ordinality, newData.description, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ulong countLists(string username) {
|
||||||
|
Database db = getDb(username);
|
||||||
|
return db.execute("SELECT COUNT(id) FROM note_list").oneValue!ulong();
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -41,16 +41,18 @@ class SqliteNoteDataSource : NoteDataSource {
|
||||||
if (newData.ordinality > note.ordinality) {
|
if (newData.ordinality > note.ordinality) {
|
||||||
// Decrement all notes between the old index and the new one.
|
// Decrement all notes between the old index and the new one.
|
||||||
db.execute(
|
db.execute(
|
||||||
"UPDATE note SET ordinality = ordinality - 1 WHERE ordinality > ? AND ordinality <= ?",
|
"UPDATE note SET ordinality = ordinality - 1 WHERE ordinality > ? AND ordinality <= ? AND note_list_id = ?",
|
||||||
note.ordinality,
|
note.ordinality,
|
||||||
newData.ordinality
|
newData.ordinality,
|
||||||
|
note.noteListId
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Increment all notes between the old index and the new one.
|
// Increment all notes between the old index and the new one.
|
||||||
db.execute(
|
db.execute(
|
||||||
"UPDATE note SET ordinality = ordinality + 1 WHERE ordinality >= ? AND ordinality < ?",
|
"UPDATE note SET ordinality = ordinality + 1 WHERE ordinality >= ? AND ordinality < ? AND note_list_id = ?",
|
||||||
newData.ordinality,
|
newData.ordinality,
|
||||||
note.ordinality
|
note.ordinality,
|
||||||
|
note.noteListId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -66,14 +68,27 @@ class SqliteNoteDataSource : NoteDataSource {
|
||||||
|
|
||||||
void deleteNote(string username, ulong id) {
|
void deleteNote(string username, ulong id) {
|
||||||
Database db = getDb(username);
|
Database db = getDb(username);
|
||||||
|
ResultRange result = db.execute("SELECT * FROM note WHERE id = ?", id);
|
||||||
|
if (result.empty) return;
|
||||||
|
Note note = parseNote(result.front);
|
||||||
db.begin();
|
db.begin();
|
||||||
Nullable!uint ordinality = db.execute(
|
|
||||||
"SELECT ordinality FROM note WHERE id = ?", id
|
|
||||||
).oneValue!(Nullable!uint)();
|
|
||||||
db.execute("DELETE FROM note WHERE id = ?", id);
|
db.execute("DELETE FROM note WHERE id = ?", id);
|
||||||
if (!ordinality.isNull) {
|
db.execute(
|
||||||
db.execute("UPDATE note SET ordinality = ordinality - 1 WHERE ordinality > ?", ordinality.get);
|
"UPDATE note SET ordinality = ordinality - 1 WHERE ordinality > ? AND note_list_id = ?",
|
||||||
}
|
note.ordinality,
|
||||||
|
note.noteListId
|
||||||
|
);
|
||||||
db.commit();
|
db.commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ulong countNotes(string username) {
|
||||||
|
return getDb(username)
|
||||||
|
.execute("SELECT COUNT(id) FROM note")
|
||||||
|
.oneValue!ulong();
|
||||||
|
}
|
||||||
|
ulong countNotes(string username, ulong noteListId) {
|
||||||
|
return getDb(username)
|
||||||
|
.execute("SELECT COUNT(id) FROM note WHERE note_list_id = ?", noteListId)
|
||||||
|
.oneValue!ulong();
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -18,9 +18,9 @@ class FileSystemUserDataSource : UserDataSource {
|
||||||
mkdirRecurse(dirPath);
|
mkdirRecurse(dirPath);
|
||||||
string dataPath = buildPath(dirPath, DATA_FILE);
|
string dataPath = buildPath(dirPath, DATA_FILE);
|
||||||
JSONValue userObj = JSONValue(string[string].init);
|
JSONValue userObj = JSONValue(string[string].init);
|
||||||
userObj.object["username"] = username;
|
userObj.object["email"] = JSONValue(email);
|
||||||
userObj.object["email"] = email;
|
userObj.object["passwordHash"] = JSONValue(passwordHash);
|
||||||
userObj.object["passwordHash"] = passwordHash;
|
userObj.object["admin"] = JSONValue(false);
|
||||||
std.file.write(dataPath, userObj.toPrettyString());
|
std.file.write(dataPath, userObj.toPrettyString());
|
||||||
return User(username, email, passwordHash);
|
return User(username, email, passwordHash);
|
||||||
}
|
}
|
||||||
|
@ -35,11 +35,16 @@ class FileSystemUserDataSource : UserDataSource {
|
||||||
string dataPath = buildPath(USERS_DIR, username, DATA_FILE);
|
string dataPath = buildPath(USERS_DIR, username, DATA_FILE);
|
||||||
if (exists(dataPath) && isFile(dataPath)) {
|
if (exists(dataPath) && isFile(dataPath)) {
|
||||||
JSONValue userObj = parseJSON(strip(readText(dataPath)));
|
JSONValue userObj = parseJSON(strip(readText(dataPath)));
|
||||||
return nullable(User(
|
string email = userObj.object["email"].str;
|
||||||
userObj.object["username"].str,
|
string passwordHash = userObj.object["passwordHash"].str;
|
||||||
userObj.object["email"].str,
|
bool admin = false;
|
||||||
userObj.object["passwordHash"].str
|
if ("admin" !in userObj.object) {
|
||||||
));
|
userObj.object["admin"] = JSONValue(false);
|
||||||
|
std.file.write(dataPath, userObj.toPrettyString());
|
||||||
|
} else {
|
||||||
|
admin = userObj.object["admin"].boolean;
|
||||||
|
}
|
||||||
|
return nullable(User(username, email, passwordHash, admin));
|
||||||
}
|
}
|
||||||
return Nullable!User.init;
|
return Nullable!User.init;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ interface NoteListDataSource {
|
||||||
NoteList createNoteList(string username, string name, string description = null);
|
NoteList createNoteList(string username, string name, string description = null);
|
||||||
void deleteNoteList(string username, ulong id);
|
void deleteNoteList(string username, ulong id);
|
||||||
NoteList updateNoteList(string username, ulong id, NoteList newData);
|
NoteList updateNoteList(string username, ulong id, NoteList newData);
|
||||||
|
ulong countLists(string username);
|
||||||
}
|
}
|
||||||
|
|
||||||
static NoteListDataSource noteListDataSource;
|
static NoteListDataSource noteListDataSource;
|
||||||
|
|
|
@ -4,6 +4,7 @@ struct User {
|
||||||
string username;
|
string username;
|
||||||
string email;
|
string email;
|
||||||
string passwordHash;
|
string passwordHash;
|
||||||
|
bool admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NoteList {
|
struct NoteList {
|
||||||
|
|
|
@ -6,6 +6,8 @@ interface NoteDataSource {
|
||||||
Note createNote(string username, ulong noteListId, string content);
|
Note createNote(string username, ulong noteListId, string content);
|
||||||
Note updateNote(string username, ulong id, Note newData);
|
Note updateNote(string username, ulong id, Note newData);
|
||||||
void deleteNote(string username, ulong id);
|
void deleteNote(string username, ulong id);
|
||||||
|
ulong countNotes(string username);
|
||||||
|
ulong countNotes(string username, ulong noteListId);
|
||||||
}
|
}
|
||||||
|
|
||||||
static NoteDataSource noteDataSource;
|
static NoteDataSource noteDataSource;
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
module endpoints.admin;
|
||||||
|
|
||||||
|
import handy_httpd;
|
||||||
|
import slf4d;
|
||||||
|
|
||||||
|
import std.file;
|
||||||
|
import std.path;
|
||||||
|
import std.json;
|
||||||
|
|
||||||
|
void getAllUsers(ref HttpRequestContext ctx) {
|
||||||
|
import data.impl.user;
|
||||||
|
import data.list;
|
||||||
|
import data.note;
|
||||||
|
|
||||||
|
JSONValue usersArray = JSONValue(string[].init);
|
||||||
|
|
||||||
|
foreach (DirEntry entry; dirEntries(USERS_DIR, SpanMode.shallow, false)) {
|
||||||
|
string username = baseName(entry.name);
|
||||||
|
JSONValue userData = parseJSON(readText(buildPath(USERS_DIR, username, DATA_FILE)));
|
||||||
|
string email = userData.object["email"].str;
|
||||||
|
bool admin = userData.object["admin"].boolean;
|
||||||
|
ulong listCount = noteListDataSource.countLists(username);
|
||||||
|
ulong noteCount = noteDataSource.countNotes(username);
|
||||||
|
JSONValue userObj = JSONValue(string[string].init);
|
||||||
|
userObj.object["username"] = JSONValue(username);
|
||||||
|
userObj.object["email"] = JSONValue(email);
|
||||||
|
userObj.object["admin"] = JSONValue(admin);
|
||||||
|
userObj.object["listCount"] = JSONValue(listCount);
|
||||||
|
userObj.object["noteCount"] = JSONValue(noteCount);
|
||||||
|
usersArray.array ~= userObj;
|
||||||
|
}
|
||||||
|
ctx.response.writeBodyString(usersArray.toString(), "application/json");
|
||||||
|
}
|
|
@ -42,8 +42,7 @@ void handleLogin(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void renewToken(ref HttpRequestContext ctx) {
|
void renewToken(ref HttpRequestContext ctx) {
|
||||||
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
|
AuthContext auth = getAuthContextOrThrow(ctx);
|
||||||
AuthContext auth = AuthContextHolder.getOrThrow();
|
|
||||||
|
|
||||||
JSONValue resp = JSONValue(string[string].init);
|
JSONValue resp = JSONValue(string[string].init);
|
||||||
resp.object["token"] = generateToken(auth.user, loadTokenSecret());
|
resp.object["token"] = generateToken(auth.user, loadTokenSecret());
|
||||||
|
@ -51,11 +50,33 @@ void renewToken(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void createNewUser(ref HttpRequestContext ctx) {
|
void createNewUser(ref HttpRequestContext ctx) {
|
||||||
|
import std.regex;
|
||||||
|
|
||||||
JSONValue userData = ctx.request.readBodyAsJson();
|
JSONValue userData = ctx.request.readBodyAsJson();
|
||||||
|
if ("username" !in userData.object || "email" !in userData.object || "password" !in userData.object) {
|
||||||
|
ctx.response.setStatus(HttpStatus.BAD_REQUEST);
|
||||||
|
ctx.response.writeBodyString("Missing required data.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
string username = userData.object["username"].str;
|
string username = userData.object["username"].str;
|
||||||
string email = userData.object["email"].str;
|
string email = userData.object["email"].str;
|
||||||
string password = userData.object["password"].str;
|
string password = userData.object["password"].str;
|
||||||
|
|
||||||
|
const ctr = ctRegex!(`^[a-zA-Z0-9][a-zA-Z0-9-_]{2,23}$`);
|
||||||
|
Captures!string c = matchFirst(username, ctr);
|
||||||
|
if (c.empty) {
|
||||||
|
ctx.response.setStatus(HttpStatus.BAD_REQUEST);
|
||||||
|
ctx.response.writeBodyString("Invalid username.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
ctx.response.setStatus(HttpStatus.BAD_REQUEST);
|
||||||
|
ctx.response.writeBodyString("Password is too short. Should be at least 8 characters.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!userDataSource.getUser(username).isNull) {
|
if (!userDataSource.getUser(username).isNull) {
|
||||||
ctx.response.setStatus(HttpStatus.BAD_REQUEST);
|
ctx.response.setStatus(HttpStatus.BAD_REQUEST);
|
||||||
ctx.response.writeBodyString("Username is taken.");
|
ctx.response.writeBodyString("Username is taken.");
|
||||||
|
@ -72,16 +93,15 @@ void createNewUser(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void getMyUser(ref HttpRequestContext ctx) {
|
void getMyUser(ref HttpRequestContext ctx) {
|
||||||
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
|
AuthContext auth = getAuthContextOrThrow(ctx);
|
||||||
AuthContext auth = AuthContextHolder.getOrThrow();
|
|
||||||
JSONValue resp = JSONValue(string[string].init);
|
JSONValue resp = JSONValue(string[string].init);
|
||||||
resp.object["username"] = JSONValue(auth.user.username);
|
resp.object["username"] = JSONValue(auth.user.username);
|
||||||
resp.object["email"] = JSONValue(auth.user.email);
|
resp.object["email"] = JSONValue(auth.user.email);
|
||||||
|
resp.object["admin"] = JSONValue(auth.user.admin);
|
||||||
ctx.response.writeBodyString(resp.toString(), "application/json");
|
ctx.response.writeBodyString(resp.toString(), "application/json");
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteMyUser(ref HttpRequestContext ctx) {
|
void deleteMyUser(ref HttpRequestContext ctx) {
|
||||||
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
|
AuthContext auth = getAuthContextOrThrow(ctx);
|
||||||
AuthContext auth = AuthContextHolder.getOrThrow();
|
|
||||||
userDataSource.deleteUser(auth.user.username);
|
userDataSource.deleteUser(auth.user.username);
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,8 +11,7 @@ import data.list;
|
||||||
import data.note;
|
import data.note;
|
||||||
|
|
||||||
void getNoteLists(ref HttpRequestContext ctx) {
|
void getNoteLists(ref HttpRequestContext ctx) {
|
||||||
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
|
AuthContext auth = getAuthContextOrThrow(ctx);
|
||||||
AuthContext auth = AuthContextHolder.getOrThrow();
|
|
||||||
NoteList[] lists = noteListDataSource.getLists(auth.user.username);
|
NoteList[] lists = noteListDataSource.getLists(auth.user.username);
|
||||||
JSONValue listsArray = JSONValue(string[].init);
|
JSONValue listsArray = JSONValue(string[].init);
|
||||||
foreach (NoteList list; lists) {
|
foreach (NoteList list; lists) {
|
||||||
|
@ -22,8 +21,7 @@ void getNoteLists(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void getNoteList(ref HttpRequestContext ctx) {
|
void getNoteList(ref HttpRequestContext ctx) {
|
||||||
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
|
AuthContext auth = getAuthContextOrThrow(ctx);
|
||||||
AuthContext auth = AuthContextHolder.getOrThrow();
|
|
||||||
ulong id = ctx.request.getPathParamAs!ulong("id");
|
ulong id = ctx.request.getPathParamAs!ulong("id");
|
||||||
Nullable!NoteList optionalList = noteListDataSource.getList(auth.user.username, id);
|
Nullable!NoteList optionalList = noteListDataSource.getList(auth.user.username, id);
|
||||||
if (!optionalList.isNull) {
|
if (!optionalList.isNull) {
|
||||||
|
@ -34,8 +32,7 @@ void getNoteList(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void createNoteList(ref HttpRequestContext ctx) {
|
void createNoteList(ref HttpRequestContext ctx) {
|
||||||
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
|
AuthContext auth = getAuthContextOrThrow(ctx);
|
||||||
AuthContext auth = AuthContextHolder.getOrThrow();
|
|
||||||
JSONValue requestBody = ctx.request.readBodyAsJson();
|
JSONValue requestBody = ctx.request.readBodyAsJson();
|
||||||
if ("name" !in requestBody.object) {
|
if ("name" !in requestBody.object) {
|
||||||
ctx.response.setStatus(HttpStatus.BAD_REQUEST);
|
ctx.response.setStatus(HttpStatus.BAD_REQUEST);
|
||||||
|
@ -56,8 +53,7 @@ void createNoteList(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void createNote(ref HttpRequestContext ctx) {
|
void createNote(ref HttpRequestContext ctx) {
|
||||||
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
|
AuthContext auth = getAuthContextOrThrow(ctx);
|
||||||
AuthContext auth = AuthContextHolder.getOrThrow();
|
|
||||||
ulong listId = ctx.request.getPathParamAs!ulong("listId");
|
ulong listId = ctx.request.getPathParamAs!ulong("listId");
|
||||||
JSONValue requestBody = ctx.request.readBodyAsJson();
|
JSONValue requestBody = ctx.request.readBodyAsJson();
|
||||||
if (
|
if (
|
||||||
|
@ -75,14 +71,12 @@ void createNote(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteNoteList(ref HttpRequestContext ctx) {
|
void deleteNoteList(ref HttpRequestContext ctx) {
|
||||||
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
|
AuthContext auth = getAuthContextOrThrow(ctx);
|
||||||
AuthContext auth = AuthContextHolder.getOrThrow();
|
|
||||||
noteListDataSource.deleteNoteList(auth.user.username, ctx.request.getPathParamAs!ulong("id"));
|
noteListDataSource.deleteNoteList(auth.user.username, ctx.request.getPathParamAs!ulong("id"));
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteNote(ref HttpRequestContext ctx) {
|
void deleteNote(ref HttpRequestContext ctx) {
|
||||||
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
|
AuthContext auth = getAuthContextOrThrow(ctx);
|
||||||
AuthContext auth = AuthContextHolder.getOrThrow();
|
|
||||||
ulong noteId = ctx.request.getPathParamAs!ulong("noteId");
|
ulong noteId = ctx.request.getPathParamAs!ulong("noteId");
|
||||||
noteDataSource.deleteNote(auth.user.username, noteId);
|
noteDataSource.deleteNote(auth.user.username, noteId);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
import {API_URL} from "@/api/base";
|
||||||
|
|
||||||
|
export interface AdminUserInfo {
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
admin: boolean
|
||||||
|
listCount: number
|
||||||
|
noteCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllUsers(token: string): Promise<AdminUserInfo[]> {
|
||||||
|
const response = await fetch(API_URL + "/admin/users", {
|
||||||
|
headers: {"Authorization": "Bearer " + token}
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
throw response
|
||||||
|
}
|
|
@ -3,10 +3,11 @@ import {API_URL} from "@/api/base";
|
||||||
export interface User {
|
export interface User {
|
||||||
username: string
|
username: string
|
||||||
email: string
|
email: string
|
||||||
|
admin: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyUser(): User {
|
export function emptyUser(): User {
|
||||||
return {username: "", email: ""}
|
return {username: "", email: "", admin: false}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginInfo {
|
export interface LoginInfo {
|
||||||
|
|
|
@ -4,26 +4,35 @@ a mobile-friendly width.
|
||||||
-->
|
-->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {Ref} from "vue";
|
import type {Ref} from "vue";
|
||||||
import {onMounted, ref} from "vue";
|
import {onMounted, onUnmounted, ref} from "vue";
|
||||||
import type {StatusInfo} from "@/api/base";
|
import type {StatusInfo} from "@/api/base";
|
||||||
import {getStatus} from "@/api/base";
|
import {getStatus} from "@/api/base";
|
||||||
import {humanFileSize} from "@/util";
|
import {humanFileSize} from "@/util";
|
||||||
|
import {useAuthStore} from "@/stores/auth";
|
||||||
|
|
||||||
const statusInfo: Ref<StatusInfo | null> = ref(null)
|
const statusInfo: Ref<StatusInfo | null> = ref(null)
|
||||||
|
const statusRefreshInterval: Ref<number | null> = ref(null);
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
statusInfo.value = await getStatus()
|
statusInfo.value = await getStatus()
|
||||||
setInterval(async () => {
|
statusRefreshInterval.value = setInterval(async () => {
|
||||||
statusInfo.value = await getStatus()
|
statusInfo.value = await getStatus()
|
||||||
}, 5000)
|
}, 5000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (statusRefreshInterval.value) {
|
||||||
|
clearInterval(statusRefreshInterval.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<slot/>
|
<slot/>
|
||||||
<!-- Each contained page also gets a nice little footer! -->
|
<!-- Each contained page also gets a nice little footer! -->
|
||||||
<footer style="text-align: center">
|
<footer style="text-align: center; margin-top: 2rem;">
|
||||||
<p style="font-size: smaller">
|
<p style="font-size: smaller">
|
||||||
LiteList created with ❤️ by
|
LiteList created with ❤️ by
|
||||||
<a href="https://andrewlalis.com" target="_blank">Andrew Lalis</a>
|
<a href="https://andrewlalis.com" target="_blank">Andrew Lalis</a>
|
||||||
|
@ -33,6 +42,9 @@ onMounted(async () => {
|
||||||
<p v-if="statusInfo" style="font-size: smaller; font-family: monospace;">
|
<p v-if="statusInfo" style="font-size: smaller; font-family: monospace;">
|
||||||
Memory used: <span v-text="humanFileSize(statusInfo.physicalMemory, true, 1)"></span>
|
Memory used: <span v-text="humanFileSize(statusInfo.physicalMemory, true, 1)"></span>
|
||||||
</p>
|
</p>
|
||||||
|
<p v-if="authStore.authenticated && authStore.user.admin" style="font-size: smaller">
|
||||||
|
<RouterLink to="/admin">Admin Page</RouterLink>
|
||||||
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -3,6 +3,7 @@ import LoginView from "@/views/LoginView.vue";
|
||||||
import ListsView from "@/views/ListsView.vue";
|
import ListsView from "@/views/ListsView.vue";
|
||||||
import {useAuthStore} from "@/stores/auth";
|
import {useAuthStore} from "@/stores/auth";
|
||||||
import SingleListView from "@/views/SingleListView.vue";
|
import SingleListView from "@/views/SingleListView.vue";
|
||||||
|
import AdminView from "@/views/AdminView.vue";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
@ -31,6 +32,10 @@ const router = createRouter({
|
||||||
{
|
{
|
||||||
path: "/lists/:id",
|
path: "/lists/:id",
|
||||||
component: SingleListView
|
component: SingleListView
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/admin",
|
||||||
|
component: AdminView
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
@ -40,12 +45,19 @@ const publicRoutes = [
|
||||||
"/login"
|
"/login"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const adminRoutes = [
|
||||||
|
"/admin"
|
||||||
|
]
|
||||||
|
|
||||||
router.beforeEach(async (to, from) => {
|
router.beforeEach(async (to, from) => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
await authStore.tryLogInFromStoredToken()
|
await authStore.tryLogInFromStoredToken()
|
||||||
if (!publicRoutes.includes(to.path) && !authStore.authenticated) {
|
if (!publicRoutes.includes(to.path) && !authStore.authenticated) {
|
||||||
return "/login" // Redirect to login page if user is trying to go to an authenticated page.
|
return "/login" // Redirect to login page if user is trying to go to an authenticated page.
|
||||||
}
|
}
|
||||||
|
if (adminRoutes.includes(to.path) && !authStore.user.admin) {
|
||||||
|
return "/lists" // Redirect to /lists if a non-admin user tries to access an admin page.
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|
|
@ -61,7 +61,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||||
try {
|
try {
|
||||||
const storedUser = await getMyUser(storedToken)
|
const storedUser = await getMyUser(storedToken)
|
||||||
console.log("Logging in using stored token for user: " + storedUser.username)
|
console.log("Logging in using stored token for user: " + storedUser.username)
|
||||||
logIn(storedToken, storedUser)
|
await logIn(storedToken, storedUser)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.warn("Failed to log in using stored token.", e)
|
console.warn("Failed to log in using stored token.", e)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import PageContainer from "@/components/PageContainer.vue";
|
||||||
|
import type {Ref} from "vue";
|
||||||
|
import type {AdminUserInfo} from "@/api/admin";
|
||||||
|
import {onMounted, ref} from "vue";
|
||||||
|
import {getAllUsers} from "@/api/admin";
|
||||||
|
import {useAuthStore} from "@/stores/auth";
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const users: Ref<AdminUserInfo[]> = ref([])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
users.value = await getAllUsers(authStore.token)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageContainer>
|
||||||
|
<h1>Admin</h1>
|
||||||
|
<p>
|
||||||
|
This is the admin page!
|
||||||
|
</p>
|
||||||
|
<h3>Users</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Admin</th>
|
||||||
|
<th>List Count</th>
|
||||||
|
<th>Note Count</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="user in users" :key="user.username">
|
||||||
|
<td v-text="user.username"/>
|
||||||
|
<td v-text="user.email"/>
|
||||||
|
<td v-text="user.admin"/>
|
||||||
|
<td v-text="user.listCount"/>
|
||||||
|
<td v-text="user.noteCount"/>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</PageContainer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
|
@ -110,7 +110,7 @@ async function createNoteAndRefresh() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="list.notes.length === 0">
|
<p v-if="list.notes.length === 0">
|
||||||
<em>There are no notes in this list.</em> <Button @click="toggleCreatingNewNote()">Add one!</Button>
|
<em>There are no notes in this list.</em> <button @click="toggleCreatingNewNote()">Add one!</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<dialog id="list-delete-dialog">
|
<dialog id="list-delete-dialog">
|
||||||
|
|
Loading…
Reference in New Issue