implemented loading of bookmarks and bookmark list
This commit is contained in:
@@ -3,10 +3,12 @@ package de.srsoftware.umbrella.bookmarks;
|
|||||||
|
|
||||||
import static de.srsoftware.umbrella.bookmarks.Constants.*;
|
import static de.srsoftware.umbrella.bookmarks.Constants.*;
|
||||||
import static de.srsoftware.umbrella.core.ConnectionProvider.connect;
|
import static de.srsoftware.umbrella.core.ConnectionProvider.connect;
|
||||||
|
import static de.srsoftware.umbrella.core.Constants.COMMENT;
|
||||||
import static de.srsoftware.umbrella.core.Constants.URL;
|
import static de.srsoftware.umbrella.core.Constants.URL;
|
||||||
|
import static de.srsoftware.umbrella.core.Paths.LIST;
|
||||||
|
import static de.srsoftware.umbrella.core.Util.mapValues;
|
||||||
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.missingFieldException;
|
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.missingFieldException;
|
||||||
import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
|
import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
|
||||||
import static java.net.HttpURLConnection.HTTP_NOT_IMPLEMENTED;
|
|
||||||
|
|
||||||
import com.sun.net.httpserver.HttpExchange;
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
import de.srsoftware.configuration.Configuration;
|
import de.srsoftware.configuration.Configuration;
|
||||||
@@ -30,6 +32,25 @@ public class BookmarkApi extends BaseHandler {
|
|||||||
users = userService;
|
users = userService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean doGet(Path path, HttpExchange ex) throws IOException {
|
||||||
|
addCors(ex);
|
||||||
|
try {
|
||||||
|
Optional<Token> token = SessionToken.from(ex).map(Token::of);
|
||||||
|
var user = users.loadUser(token);
|
||||||
|
if (user.isEmpty()) return unauthorized(ex);
|
||||||
|
var head = path.pop();
|
||||||
|
return switch (head) {
|
||||||
|
case LIST -> getUserBookmarks(user.get(),ex);
|
||||||
|
case null, default -> super.doPost(path,ex);
|
||||||
|
};
|
||||||
|
} catch (NumberFormatException e){
|
||||||
|
return sendContent(ex,HTTP_BAD_REQUEST,"Invalid project id");
|
||||||
|
} catch (UmbrellaException e){
|
||||||
|
return send(ex,e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean doPost(Path path, HttpExchange ex) throws IOException {
|
public boolean doPost(Path path, HttpExchange ex) throws IOException {
|
||||||
addCors(ex);
|
addCors(ex);
|
||||||
@@ -49,11 +70,16 @@ public class BookmarkApi extends BaseHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean getUserBookmarks(UmbrellaUser user, HttpExchange ex) throws IOException {
|
||||||
|
var bookmarks = db.list(user.id());
|
||||||
|
return sendContent(ex,mapValues(bookmarks));
|
||||||
|
}
|
||||||
|
|
||||||
private boolean postBookmark(UmbrellaUser user, HttpExchange ex) throws IOException {
|
private boolean postBookmark(UmbrellaUser user, HttpExchange ex) throws IOException {
|
||||||
var json = json(ex);
|
var json = json(ex);
|
||||||
if (!(json.has(URL) && json.get(URL) instanceof String url)) throw missingFieldException(URL);
|
if (!(json.has(URL) && json.get(URL) instanceof String url)) throw missingFieldException(URL);
|
||||||
if (!(json.has(COMMENT) && json.get(COMMENT) instanceof String comment)) throw missingFieldException(COMMENT);
|
if (!(json.has(COMMENT) && json.get(COMMENT) instanceof String comment)) throw missingFieldException(COMMENT);
|
||||||
var urlHash = db.save(url,comment, user.id());
|
var bookmark = db.save(url,comment, user.id());
|
||||||
return sendContent(ex,urlHash);
|
return sendContent(ex,bookmark);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
/* © SRSoftware 2025 */
|
/* © SRSoftware 2025 */
|
||||||
package de.srsoftware.umbrella.bookmarks;
|
package de.srsoftware.umbrella.bookmarks;
|
||||||
|
|
||||||
|
import de.srsoftware.umbrella.core.model.Bookmark;
|
||||||
|
import de.srsoftware.umbrella.core.model.Hash;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
public interface BookmarkDb {
|
public interface BookmarkDb {
|
||||||
String save(String url, String comment, long userId);
|
Bookmark save(String url, String comment, long userId);
|
||||||
|
|
||||||
|
Map<Hash, Bookmark> list(long id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
package de.srsoftware.umbrella.bookmarks;
|
package de.srsoftware.umbrella.bookmarks;
|
||||||
|
|
||||||
public class Constants {
|
public class Constants {
|
||||||
public static final String COMMENT = "comment";
|
|
||||||
public static final String CONFIG_DATABASE = "umbrella.modules.bookmark.database";
|
public static final String CONFIG_DATABASE = "umbrella.modules.bookmark.database";
|
||||||
public static final String HASH = "hash";
|
|
||||||
public static final String SAVE = "save";
|
public static final String SAVE = "save";
|
||||||
public static final String TABLE_URLS = "urls";
|
public static final String TABLE_URLS = "urls";
|
||||||
public static final String TABLE_URL_COMMENTS = "url_comments";
|
public static final String TABLE_URL_COMMENTS = "url_comments";
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
/* © SRSoftware 2025 */
|
/* © SRSoftware 2025 */
|
||||||
package de.srsoftware.umbrella.bookmarks;
|
package de.srsoftware.umbrella.bookmarks;
|
||||||
|
|
||||||
import static de.srsoftware.tools.jdbc.Query.insertInto;
|
import static de.srsoftware.tools.jdbc.Query.*;
|
||||||
import static de.srsoftware.tools.jdbc.Query.replaceInto;
|
import static de.srsoftware.tools.jdbc.Query.SelectQuery.ALL;
|
||||||
import static de.srsoftware.umbrella.bookmarks.Constants.*;
|
import static de.srsoftware.umbrella.bookmarks.Constants.*;
|
||||||
import static de.srsoftware.umbrella.core.Constants.*;
|
import static de.srsoftware.umbrella.core.Constants.*;
|
||||||
import static de.srsoftware.umbrella.core.Constants.ERROR_FAILED_CREATE_TABLE;
|
import static de.srsoftware.umbrella.core.Constants.ERROR_FAILED_CREATE_TABLE;
|
||||||
import static de.srsoftware.umbrella.core.Util.sha1;
|
import static de.srsoftware.umbrella.core.Util.sha1;
|
||||||
import static java.lang.System.Logger.Level.ERROR;
|
import static java.lang.System.Logger.Level.ERROR;
|
||||||
|
import static java.text.MessageFormat.format;
|
||||||
|
import static java.time.ZoneOffset.UTC;
|
||||||
|
|
||||||
|
import de.srsoftware.tools.jdbc.Condition;
|
||||||
import de.srsoftware.umbrella.core.BaseDb;
|
import de.srsoftware.umbrella.core.BaseDb;
|
||||||
import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
|
import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
|
||||||
|
import de.srsoftware.umbrella.core.model.Bookmark;
|
||||||
|
import de.srsoftware.umbrella.core.model.Hash;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
public class SqliteDb extends BaseDb implements BookmarkDb {
|
public class SqliteDb extends BaseDb implements BookmarkDb {
|
||||||
public SqliteDb(Connection conn) {
|
public SqliteDb(Connection conn) {
|
||||||
@@ -32,14 +40,15 @@ public class SqliteDb extends BaseDb implements BookmarkDb {
|
|||||||
|
|
||||||
private void createUrlCommentsTable() {
|
private void createUrlCommentsTable() {
|
||||||
var sql = """
|
var sql = """
|
||||||
CREATE TABLE IF NOT EXISTS "url_comments" (
|
CREATE TABLE IF NOT EXISTS {0} (
|
||||||
`hash` VARCHAR ( 255 ) NOT NULL,
|
`{1}` VARCHAR ( 255 ) NOT NULL,
|
||||||
`user_id` LONG NOT NULL,
|
`{2}` LONG NOT NULL,
|
||||||
`comment` TEXT NOT NULL,
|
`{3}` TEXT NOT NULL,
|
||||||
PRIMARY KEY (`hash`,`user_id`)
|
`{4}` DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (`{1}`,`{2}`)
|
||||||
)""";
|
)""";
|
||||||
try {
|
try {
|
||||||
var stmt = db.prepareStatement(sql);
|
var stmt = db.prepareStatement(format(sql,TABLE_URL_COMMENTS,HASH,USER_ID,COMMENT,TIMESTAMP));
|
||||||
stmt.execute();
|
stmt.execute();
|
||||||
stmt.close();
|
stmt.close();
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
@@ -61,15 +70,33 @@ CREATE TABLE IF NOT EXISTS "url_comments" (
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String save(String url, String comment, long userId) {
|
public Map<Hash, Bookmark> list(long userId) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
var map = new HashMap<Hash,Bookmark>();
|
||||||
|
var rs = select(ALL).from(TABLE_URL_COMMENTS).leftJoin(HASH,TABLE_URLS,HASH).where(USER_ID, Condition.equal(userId)).exec(db);
|
||||||
|
while (rs.next()){
|
||||||
|
var bookmark = Bookmark.of(rs);
|
||||||
|
map.put(bookmark.hash(),bookmark);
|
||||||
|
}
|
||||||
|
rs.close();;
|
||||||
|
return map;
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new UmbrellaException("Failed to load bookmark list");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Bookmark save(String url, String comment, long userId) {
|
||||||
var hash = sha1(url);
|
var hash = sha1(url);
|
||||||
try {
|
try {
|
||||||
|
var timestamp = LocalDateTime.now();
|
||||||
replaceInto(TABLE_URLS,HASH,URL)
|
replaceInto(TABLE_URLS,HASH,URL)
|
||||||
.values(hash,url).execute(db).close();
|
.values(hash,url).execute(db).close();
|
||||||
replaceInto(TABLE_URL_COMMENTS,HASH,USER_ID,COMMENT)
|
replaceInto(TABLE_URL_COMMENTS,HASH,USER_ID,COMMENT, TIMESTAMP)
|
||||||
.values(hash,userId,comment)
|
.values(hash,userId,comment,timestamp.toEpochSecond(UTC))
|
||||||
.execute(db).close();
|
.execute(db).close();
|
||||||
return hash;
|
return Bookmark.of(url,comment,timestamp);
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
throw new UmbrellaException("Failed to store url");
|
throw new UmbrellaException("Failed to store url");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public class Constants {
|
|||||||
public static final String BODY = "body";
|
public static final String BODY = "body";
|
||||||
|
|
||||||
public static final String CODE = "code";
|
public static final String CODE = "code";
|
||||||
|
public static final String COMMENT = "comment";
|
||||||
public static final String COMPANY = "company";
|
public static final String COMPANY = "company";
|
||||||
public static final String COMPANY_ID = "company_id";
|
public static final String COMPANY_ID = "company_id";
|
||||||
public static final String CONTENT_TYPE = "Content-Type";
|
public static final String CONTENT_TYPE = "Content-Type";
|
||||||
@@ -57,6 +58,8 @@ public class Constants {
|
|||||||
|
|
||||||
public static final String GET = "GET";
|
public static final String GET = "GET";
|
||||||
|
|
||||||
|
public static final String HASH = "hash";
|
||||||
|
|
||||||
public static final String ID = "id";
|
public static final String ID = "id";
|
||||||
|
|
||||||
public static final String JSONARRAY = "json array";
|
public static final String JSONARRAY = "json array";
|
||||||
@@ -112,6 +115,7 @@ public class Constants {
|
|||||||
public static final String TITLE = "title";
|
public static final String TITLE = "title";
|
||||||
public static final String TIMESTAMP = "timestamp";
|
public static final String TIMESTAMP = "timestamp";
|
||||||
public static final String TOKEN = "token";
|
public static final String TOKEN = "token";
|
||||||
|
public static final String TYPE = "type";
|
||||||
|
|
||||||
public static final String UMBRELLA = "Umbrella";
|
public static final String UMBRELLA = "Umbrella";
|
||||||
public static final String URL = "url";
|
public static final String URL = "url";
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import com.xrbpowered.jparsedown.JParsedown;
|
|||||||
import de.srsoftware.tools.Mappable;
|
import de.srsoftware.tools.Mappable;
|
||||||
import de.srsoftware.tools.Query;
|
import de.srsoftware.tools.Query;
|
||||||
import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
|
import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
|
||||||
|
import de.srsoftware.umbrella.core.model.Hash;
|
||||||
import java.io.*;
|
import java.io.*;
|
||||||
import java.net.HttpURLConnection;
|
import java.net.HttpURLConnection;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
@@ -32,11 +33,12 @@ public class Util {
|
|||||||
private static final Pattern UML_PATTERN = Pattern.compile("@start(\\w+)(.*)@end(\\1)",Pattern.DOTALL);
|
private static final Pattern UML_PATTERN = Pattern.compile("@start(\\w+)(.*)@end(\\1)",Pattern.DOTALL);
|
||||||
private static File plantumlJar = null;
|
private static File plantumlJar = null;
|
||||||
private static final JParsedown MARKDOWN = new JParsedown();
|
private static final JParsedown MARKDOWN = new JParsedown();
|
||||||
private static final MessageDigest SHA1;
|
public static final String SHA1 = "SHA-1";
|
||||||
|
private static final MessageDigest SHA1_DIGEST;
|
||||||
|
|
||||||
static {
|
static {
|
||||||
try {
|
try {
|
||||||
SHA1 = MessageDigest.getInstance("SHA-1");
|
SHA1_DIGEST = MessageDigest.getInstance(SHA1);
|
||||||
} catch (NoSuchAlgorithmException e) {
|
} catch (NoSuchAlgorithmException e) {
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
@@ -54,8 +56,8 @@ public class Util {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Map<Long,Map<String,Object>> mapValues(Map<Long, ? extends Mappable> map){
|
public static <KEY> Map<KEY,Map<String,Object>> mapValues(Map<KEY, ? extends Mappable> map){
|
||||||
var result = new HashMap<Long,Map<String,Object>>();
|
var result = new HashMap<KEY,Map<String,Object>>();
|
||||||
for (var entry : map.entrySet()) result.put(entry.getKey(),entry.getValue().toMap());
|
for (var entry : map.entrySet()) result.put(entry.getKey(),entry.getValue().toMap());
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -167,9 +169,9 @@ public class Util {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String sha1(String plain){
|
public static Hash sha1(String plain){
|
||||||
var bytes = SHA1.digest(plain.getBytes(UTF_8));
|
var bytes = SHA1_DIGEST.digest(plain.getBytes(UTF_8));
|
||||||
return hex(bytes);
|
return new Hash(hex(bytes),SHA1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void setPlantUmlJar(File file){
|
public static void setPlantUmlJar(File file){
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/* © SRSoftware 2025 */
|
||||||
|
package de.srsoftware.umbrella.core.model;
|
||||||
|
|
||||||
|
import static de.srsoftware.umbrella.core.Constants.*;
|
||||||
|
import static de.srsoftware.umbrella.core.Util.SHA1;
|
||||||
|
import static de.srsoftware.umbrella.core.Util.sha1;
|
||||||
|
import static java.time.ZoneOffset.UTC;
|
||||||
|
|
||||||
|
import de.srsoftware.tools.Mappable;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public record Bookmark(String url, Hash hash, String comment, LocalDateTime timestamp, Collection<String> tags) implements Mappable {
|
||||||
|
|
||||||
|
public static Bookmark of(ResultSet rs) throws SQLException {
|
||||||
|
return new Bookmark(rs.getString(URL),new Hash(rs.getString(HASH),SHA1),rs.getString(COMMENT),LocalDateTime.ofEpochSecond(rs.getLong(TIMESTAMP),0, UTC),new ArrayList<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Bookmark of(String url, String comment, LocalDateTime timestamp){
|
||||||
|
return new Bookmark(url,sha1(url),comment,timestamp,new ArrayList<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> toMap() {
|
||||||
|
return Map.of(
|
||||||
|
URL, url,
|
||||||
|
COMMENT, comment,
|
||||||
|
HASH, Map.of(VALUE,hash.value(),TYPE,hash.type()),
|
||||||
|
TAGS, tags,
|
||||||
|
TIMESTAMP, timestamp.withNano(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/* © SRSoftware 2025 */
|
||||||
|
package de.srsoftware.umbrella.core.model;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public record Hash(String value, String type){
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (!(o instanceof Hash(String v, String t))) return false;
|
||||||
|
return Objects.equals(type, t) && Objects.equals(value, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(value, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,31 @@
|
|||||||
<script>
|
<script>
|
||||||
import { api } from '../../urls.svelte.js';
|
import { onMount } from 'svelte';
|
||||||
import { t } from '../../translations.svelte.js';
|
|
||||||
|
|
||||||
import Editor from '../../Components/MarkdownEditor.svelte';
|
import { api } from '../../urls.svelte.js';
|
||||||
|
import { t } from '../../translations.svelte.js';
|
||||||
|
|
||||||
let comment = $state({source:null,rendered:null});
|
import Editor from '../../Components/MarkdownEditor.svelte';
|
||||||
let error = $state(null);
|
|
||||||
let link = $state(null);
|
let bookmarks = $state(null);
|
||||||
|
let comment = $state({source:null,rendered:null});
|
||||||
|
let error = $state(null);
|
||||||
|
let link = $state(null);
|
||||||
|
|
||||||
|
async function loadBookmarks(){
|
||||||
|
const url = api('bookmark/list');
|
||||||
|
const resp = await fetch(url,{credentials:'include'});
|
||||||
|
if (resp.ok){
|
||||||
|
const raw = await resp.json();
|
||||||
|
bookmarks = Object.values(raw)
|
||||||
|
.sort(
|
||||||
|
(a, b) => new Date(b.timestamp) - new Date(a.timestamp)
|
||||||
|
);
|
||||||
|
|
||||||
|
error = false;
|
||||||
|
} else {
|
||||||
|
error = await resp.html();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onclick(ev){
|
async function onclick(ev){
|
||||||
let data = {
|
let data = {
|
||||||
@@ -17,13 +36,17 @@
|
|||||||
const resp = await fetch(url,{
|
const resp = await fetch(url,{
|
||||||
credentials : 'include',
|
credentials : 'include',
|
||||||
method : 'POST',
|
method : 'POST',
|
||||||
body : JSON.stringify(data)
|
body : JSON.stringify(data)
|
||||||
});
|
});
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
|
const bookmark = await resp.json();
|
||||||
|
bookmarks.unshift(bookmark);
|
||||||
} else {
|
} else {
|
||||||
error = await resp.text();
|
error = await resp.text();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(loadBookmarks);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -40,4 +63,17 @@
|
|||||||
<Editor simple={true} bind:value={comment} />
|
<Editor simple={true} bind:value={comment} />
|
||||||
</label>
|
</label>
|
||||||
<button {onclick}>{t('save')}</button>
|
<button {onclick}>{t('save')}</button>
|
||||||
|
{#if bookmarks}
|
||||||
|
{#each bookmarks as bookmark}
|
||||||
|
<fieldset class="bookmark">
|
||||||
|
<legend>
|
||||||
|
<a href={bookmark.url} target="_blank" class="url">{bookmark.url}</a>
|
||||||
|
</legend>
|
||||||
|
<legend class="date">
|
||||||
|
{bookmark.timestamp.replace('T',' ')}
|
||||||
|
</legend>
|
||||||
|
{bookmark.comment}
|
||||||
|
</fieldset>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -244,3 +244,14 @@ textarea{
|
|||||||
color: orange;
|
color: orange;
|
||||||
border: 0 none;
|
border: 0 none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fieldset.bookmark{
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset.bookmark legend.date{
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: -17px;
|
||||||
|
background: black;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user