mirror of
https://github.com/etesync/android
synced 2025-02-23 21:02:26 +00:00
New collection/service discovery: CalDAV+CardDAV
This commit is contained in:
parent
18542adb2c
commit
7fc01503d5
@ -9,7 +9,6 @@ package at.bitfire.davdroid.resource;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
|
||||
import com.squareup.okhttp.HttpUrl;
|
||||
|
||||
@ -17,7 +16,6 @@ import org.xbill.DNS.Lookup;
|
||||
import org.xbill.DNS.Record;
|
||||
import org.xbill.DNS.SRVRecord;
|
||||
import org.xbill.DNS.TXTRecord;
|
||||
import org.xbill.DNS.TextParseException;
|
||||
import org.xbill.DNS.Type;
|
||||
|
||||
import java.io.IOException;
|
||||
@ -33,6 +31,8 @@ import at.bitfire.dav4android.UrlUtils;
|
||||
import at.bitfire.dav4android.exception.DavException;
|
||||
import at.bitfire.dav4android.exception.HttpException;
|
||||
import at.bitfire.dav4android.exception.NotFoundException;
|
||||
import at.bitfire.dav4android.property.AddressbookDescription;
|
||||
import at.bitfire.dav4android.property.AddressbookHomeSet;
|
||||
import at.bitfire.dav4android.property.CalendarColor;
|
||||
import at.bitfire.dav4android.property.CalendarDescription;
|
||||
import at.bitfire.dav4android.property.CalendarHomeSet;
|
||||
@ -49,11 +49,21 @@ import lombok.NonNull;
|
||||
public class DavResourceFinder {
|
||||
private final static String TAG = "davdroid.ResourceFinder";
|
||||
|
||||
protected enum Service {
|
||||
CALDAV("caldav"),
|
||||
CARDDAV("carddav");
|
||||
|
||||
final String name;
|
||||
Service(String name) { this.name = name;}
|
||||
@Override public String toString() { return name; }
|
||||
};
|
||||
|
||||
protected Context context;
|
||||
protected final HttpClient httpClient;
|
||||
protected final ServerInfo serverInfo;
|
||||
|
||||
protected List<ServerInfo.ResourceInfo>
|
||||
addressbooks = new LinkedList<>(),
|
||||
calendars = new LinkedList<>(),
|
||||
taskLists = new LinkedList<>();
|
||||
|
||||
@ -66,12 +76,25 @@ public class DavResourceFinder {
|
||||
}
|
||||
|
||||
public void findResources() throws URISyntaxException, IOException, HttpException, DavException {
|
||||
findResources(Service.CARDDAV);
|
||||
findResources(Service.CALDAV);
|
||||
}
|
||||
|
||||
public void findResources(Service service) throws URISyntaxException, IOException, HttpException, DavException { {
|
||||
URI baseURI = serverInfo.getBaseURI();
|
||||
String domain = null;
|
||||
|
||||
HttpUrl principalUrl = null;
|
||||
Set<HttpUrl> calendarHomeSets = new HashSet<>();
|
||||
Set<HttpUrl> calendarHomeSets = new HashSet<>(),
|
||||
addressbookHomeSets = new HashSet<>();
|
||||
|
||||
if (service == Service.CALDAV) {
|
||||
calendars.clear();
|
||||
taskLists.clear();
|
||||
} else if (service == Service.CARDDAV)
|
||||
addressbooks.clear();
|
||||
|
||||
Constants.log.info("STARTING COLLECTION DISCOVERY FOR SERVICE " + service);
|
||||
if ("http".equals(baseURI.getScheme()) || "https".equals(baseURI.getScheme())) {
|
||||
HttpUrl userURL = HttpUrl.get(baseURI);
|
||||
|
||||
@ -82,16 +105,26 @@ public class DavResourceFinder {
|
||||
Constants.log.info("Check whether user-given URL is a calendar collection and/or contains <calendar-home-set> and/or has <current-user-principal>");
|
||||
DavResource davBase = new DavResource(httpClient, userURL);
|
||||
try {
|
||||
if (service == Service.CALDAV) {
|
||||
davBase.propfind(0,
|
||||
CalendarHomeSet.NAME, SupportedCalendarComponentSet.NAME,
|
||||
ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME,
|
||||
CurrentUserPrincipal.NAME
|
||||
);
|
||||
addIfCalendar(davBase);
|
||||
} else if (service == Service.CARDDAV) {
|
||||
davBase.propfind(0,
|
||||
AddressbookHomeSet.NAME,
|
||||
ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME, CurrentUserPrivilegeSet.NAME,
|
||||
CurrentUserPrincipal.NAME
|
||||
);
|
||||
addIfAddressBook(davBase);
|
||||
}
|
||||
} catch (IOException|HttpException|DavException e) {
|
||||
Constants.log.debug("PROPFIND on user-given URL failed", e);
|
||||
}
|
||||
|
||||
if (service == Service.CALDAV) {
|
||||
CalendarHomeSet calendarHomeSet = (CalendarHomeSet)davBase.properties.get(CalendarHomeSet.NAME);
|
||||
if (calendarHomeSet != null) {
|
||||
Constants.log.info("Found <calendar-home-set> at user-given URL");
|
||||
@ -101,6 +134,17 @@ public class DavResourceFinder {
|
||||
calendarHomeSets.add(url);
|
||||
}
|
||||
}
|
||||
} else if (service == Service.CARDDAV) {
|
||||
AddressbookHomeSet addressbookHomeSet = (AddressbookHomeSet)davBase.properties.get(AddressbookHomeSet.NAME);
|
||||
if (addressbookHomeSet != null) {
|
||||
Constants.log.info("Found <addressbook-home-set> at user-given URL");
|
||||
for (String href : addressbookHomeSet.hrefs) {
|
||||
HttpUrl url = userURL.resolve(href);
|
||||
if (url != null)
|
||||
addressbookHomeSets.add(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* When home sets haven already been found, skip further searching.
|
||||
* Otherwise (no home sets found), treat the user-given URL as "initial context path" for service discovery. */
|
||||
@ -112,12 +156,8 @@ public class DavResourceFinder {
|
||||
principalUrl = davBase.location.resolve(currentUserPrincipal.href);
|
||||
|
||||
if (principalUrl == null) {
|
||||
Constants.log.info("User-given URL doesn't contain <current-user-principal>, trying /.well-known/caldav");
|
||||
try {
|
||||
principalUrl = getCurrentUserPrincipal(userURL.resolve("/.well-known/caldav"));
|
||||
} catch (IOException|HttpException|DavException e) {
|
||||
Constants.log.debug("PROPFIND on /.well-known/caldav failed", e);
|
||||
}
|
||||
Constants.log.info("User-given URL doesn't contain <current-user-principal>, trying /.well-known/" + service.name);
|
||||
principalUrl = getCurrentUserPrincipal(userURL.resolve("/.well-known/" + service.name));
|
||||
}
|
||||
}
|
||||
|
||||
@ -136,32 +176,49 @@ public class DavResourceFinder {
|
||||
|
||||
if (principalUrl == null && domain != null) {
|
||||
Constants.log.info("No principal URL yet, trying SRV/TXT records with domain " + domain);
|
||||
principalUrl = discoverPrincipalUrl(domain, "caldavs");
|
||||
principalUrl = discoverPrincipalUrl(domain, service);
|
||||
}
|
||||
|
||||
// principal URL has been found, get calendar-home-set
|
||||
// principal URL has been found, get addressbook-home-set/calendar-home-set
|
||||
if (principalUrl != null) {
|
||||
Constants.log.info("Principal URL=" + principalUrl + ", getting <calendar-home-set>");
|
||||
try {
|
||||
DavResource principal = new DavResource(httpClient, principalUrl);
|
||||
|
||||
if (service == Service.CALDAV) {
|
||||
principal.propfind(0, CalendarHomeSet.NAME);
|
||||
CalendarHomeSet calendarHomeSet = (CalendarHomeSet)principal.properties.get(CalendarHomeSet.NAME);
|
||||
if (calendarHomeSet != null)
|
||||
if (calendarHomeSet != null) {
|
||||
Constants.log.info("Found <calendar-home-set> at principal URL");
|
||||
for (String href : calendarHomeSet.hrefs) {
|
||||
HttpUrl url = principal.location.resolve(href);
|
||||
if (url != null)
|
||||
calendarHomeSets.add(url);
|
||||
}
|
||||
}
|
||||
} else if (service == Service.CARDDAV) {
|
||||
principal.propfind(0, AddressbookHomeSet.NAME);
|
||||
AddressbookHomeSet addressbookHomeSet = (AddressbookHomeSet)principal.properties.get(AddressbookHomeSet.NAME);
|
||||
if (addressbookHomeSet != null) {
|
||||
Constants.log.info("Found <addressbook-home-set> at principal URL");
|
||||
for (String href : addressbookHomeSet.hrefs) {
|
||||
HttpUrl url = principal.location.resolve(href);
|
||||
if (url != null)
|
||||
addressbookHomeSets.add(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (IOException|HttpException|DavException e) {
|
||||
Constants.log.debug("PROPFIND on " + principalUrl + " failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
// now query all home sets
|
||||
if (service == Service.CALDAV)
|
||||
for (HttpUrl url : calendarHomeSets)
|
||||
try {
|
||||
Constants.log.info("Listing collections in home set " + url);
|
||||
Constants.log.info("Listing calendar collections in home set " + url);
|
||||
DavResource homeSet = new DavResource(httpClient, url);
|
||||
homeSet.propfind(1, SupportedCalendarComponentSet.NAME, ResourceType.NAME, DisplayName.NAME, CurrentUserPrivilegeSet.NAME,
|
||||
CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME);
|
||||
@ -175,14 +232,43 @@ public class DavResourceFinder {
|
||||
} catch (IOException|HttpException|DavException e) {
|
||||
Constants.log.debug("PROPFIND on " + url + " failed", e);
|
||||
}
|
||||
else if (service == Service.CARDDAV)
|
||||
for (HttpUrl url : addressbookHomeSets)
|
||||
try {
|
||||
Constants.log.info("Listing address books in home set " + url);
|
||||
DavResource homeSet = new DavResource(httpClient, url);
|
||||
homeSet.propfind(1, ResourceType.NAME, DisplayName.NAME, CurrentUserPrivilegeSet.NAME, AddressbookDescription.NAME);
|
||||
|
||||
// TODO CardDAV
|
||||
// home set should not be an address book, but some servers have only one address book and it's the home set
|
||||
addIfAddressBook(homeSet);
|
||||
|
||||
// members of the home set can be calendars, too
|
||||
for (DavResource member : homeSet.members)
|
||||
addIfAddressBook(member);
|
||||
} catch (IOException|HttpException|DavException e) {
|
||||
Constants.log.debug("PROPFIND on " + url + " failed", e);
|
||||
}
|
||||
|
||||
// TODO remove duplicates
|
||||
// TODO notify user on errors?
|
||||
|
||||
if (service == Service.CALDAV) {
|
||||
serverInfo.setCalendars(calendars);
|
||||
serverInfo.setTaskLists(taskLists);
|
||||
} else if (service == Service.CARDDAV)
|
||||
serverInfo.setAddressBooks(addressbooks);
|
||||
}}
|
||||
|
||||
/**
|
||||
* If the given DavResource is a #{@link ResourceType#ADDRESSBOOK}, add it to #{@link #addressbooks}.
|
||||
* @param dav DavResource to check
|
||||
*/
|
||||
protected void addIfAddressBook(@NonNull DavResource dav) {
|
||||
ResourceType resourceType = (ResourceType)dav.properties.get(ResourceType.NAME);
|
||||
if (resourceType != null && resourceType.types.contains(ResourceType.ADDRESSBOOK)) {
|
||||
Constants.log.info("Found address book at " + dav.location);
|
||||
addressbooks.add(resourceInfo(dav, ServerInfo.ResourceInfo.Type.ADDRESS_BOOK));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -204,21 +290,24 @@ public class DavResourceFinder {
|
||||
supportsTasks = supportedCalendarComponentSet.supportsTasks;
|
||||
}
|
||||
if (supportsEvents)
|
||||
calendars.add(resourceInfo(dav));
|
||||
calendars.add(resourceInfo(dav, ServerInfo.ResourceInfo.Type.CALENDAR));
|
||||
if (supportsTasks)
|
||||
taskLists.add(resourceInfo(dav));
|
||||
taskLists.add(resourceInfo(dav, ServerInfo.ResourceInfo.Type.CALENDAR));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a #{@link at.bitfire.davdroid.resource.ServerInfo.ResourceInfo} from a given
|
||||
* #{@link DavResource}. Uses the DAV properties current-user-properties, current-user-privilege-set,
|
||||
* displayname, calendar-description and calendar-color. Make sure you have queried these
|
||||
* properties from the DavResource.
|
||||
* #{@link DavResource}. Uses these DAV properties:
|
||||
* <ul>
|
||||
* <li>calendars: current-user-properties, current-user-privilege-set, displayname, calendar-description, calendar-color</li>
|
||||
* <li>address books: current-user-properties, current-user-privilege-set, displayname, addressbook-description</li>
|
||||
* </ul>. Make sure you have queried these properties from the DavResource.
|
||||
* @param dav DavResource to take the resource info from
|
||||
* @param type must be ADDRESS_BOOK or CALENDAR
|
||||
* @return ResourceInfo which represents the DavResource
|
||||
*/
|
||||
protected ServerInfo.ResourceInfo resourceInfo(DavResource dav) {
|
||||
protected ServerInfo.ResourceInfo resourceInfo(DavResource dav, ServerInfo.ResourceInfo.Type type) {
|
||||
boolean readOnly = false;
|
||||
CurrentUserPrivilegeSet privilegeSet = (CurrentUserPrivilegeSet)dav.properties.get(CurrentUserPrivilegeSet.NAME);
|
||||
if (privilegeSet != null)
|
||||
@ -232,17 +321,23 @@ public class DavResourceFinder {
|
||||
title = UrlUtils.lastSegment(dav.location);
|
||||
|
||||
String description = null;
|
||||
Integer color = null;
|
||||
if (type == ServerInfo.ResourceInfo.Type.ADDRESS_BOOK) {
|
||||
AddressbookDescription addressbookDescription = (AddressbookDescription)dav.properties.get(AddressbookDescription.NAME);
|
||||
if (addressbookDescription != null)
|
||||
description = addressbookDescription.description;
|
||||
} else if (type == ServerInfo.ResourceInfo.Type.CALENDAR) {
|
||||
CalendarDescription calendarDescription = (CalendarDescription)dav.properties.get(CalendarDescription.NAME);
|
||||
if (calendarDescription != null)
|
||||
description = calendarDescription.description;
|
||||
|
||||
Integer color = null;
|
||||
CalendarColor calendarColor = (CalendarColor)dav.properties.get(CalendarColor.NAME);
|
||||
if (calendarColor != null)
|
||||
color = calendarColor.color;
|
||||
}
|
||||
|
||||
return new ServerInfo.ResourceInfo(
|
||||
ServerInfo.ResourceInfo.Type.CALENDAR,
|
||||
type,
|
||||
readOnly,
|
||||
dav.location.toString(),
|
||||
title,
|
||||
@ -254,17 +349,17 @@ public class DavResourceFinder {
|
||||
/**
|
||||
* Try to find the principal URL by performing service discovery on a given domain name.
|
||||
* @param domain domain name, e.g. "icloud.com"
|
||||
* @param serviceName service name: "caldavs" or "carddavs"
|
||||
* @param service service to discover (CALDAV or CARDDAV)
|
||||
* @return principal URL, or null if none found
|
||||
*/
|
||||
protected HttpUrl discoverPrincipalUrl(String domain, String serviceName) {
|
||||
protected HttpUrl discoverPrincipalUrl(String domain, Service service) {
|
||||
String scheme = null;
|
||||
String fqdn = null;
|
||||
Integer port = null;
|
||||
String path = null;
|
||||
List<String> paths = new LinkedList<>(); // there may be multiple paths to try
|
||||
|
||||
try {
|
||||
final String query = "_" + serviceName + "._tcp." + domain;
|
||||
final String query = "_" + service.name + "s._tcp." + domain;
|
||||
Constants.log.debug("Looking up SRV records for " + query);
|
||||
Record[] records = new Lookup(query, Type.SRV).run();
|
||||
if (records != null && records.length >= 1) {
|
||||
@ -274,7 +369,7 @@ public class DavResourceFinder {
|
||||
scheme = "https";
|
||||
fqdn = srv.getTarget().toString(true);
|
||||
port = srv.getPort();
|
||||
Constants.log.info("Found " + serviceName + " service: fqdn=" + fqdn + ", port=" + port);
|
||||
Constants.log.info("Found " + service + " service: fqdn=" + fqdn + ", port=" + port);
|
||||
|
||||
// look for TXT record too (for initial context path)
|
||||
records = new Lookup(domain, Type.TXT).run();
|
||||
@ -282,33 +377,35 @@ public class DavResourceFinder {
|
||||
TXTRecord txt = (TXTRecord)records[0];
|
||||
for (String segment : (String[])txt.getStrings().toArray(new String[0]))
|
||||
if (segment.startsWith("path=")) {
|
||||
path = segment.substring(5);
|
||||
Constants.log.info("Found TXT record; initial context path=" + path);
|
||||
paths.add(segment.substring(5));
|
||||
Constants.log.info("Found TXT record; initial context path=" + paths);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (path == null) // no path from TXT records, use .well-known
|
||||
path = "/.well-known/caldav";
|
||||
// if there's TXT record if it it's wrong, try well-known
|
||||
paths.add("/.well-known/" + service.name);
|
||||
// if this fails, too, try "/"
|
||||
paths.add("/");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Constants.log.debug("SRV/TXT record discovery failed", e);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(scheme) && !TextUtils.isEmpty(fqdn) && port != null && path != null) {
|
||||
for (String path : paths) {
|
||||
if (!TextUtils.isEmpty(scheme) && !TextUtils.isEmpty(fqdn) && port != null && paths != null) {
|
||||
HttpUrl initialContextPath = new HttpUrl.Builder()
|
||||
.scheme(scheme)
|
||||
.host(fqdn).port(port)
|
||||
.encodedPath(path)
|
||||
.build();
|
||||
|
||||
HttpUrl principal = null;
|
||||
try {
|
||||
principal = getCurrentUserPrincipal(initialContextPath);
|
||||
} catch(NotFoundException e) {
|
||||
principal = getCurrentUserPrincipal(initialContextPath.resolve("/"));
|
||||
}
|
||||
Constants.log.info("Trying to determine principal from initial context path=" + initialContextPath);
|
||||
HttpUrl principal = getCurrentUserPrincipal(initialContextPath);
|
||||
if (principal != null)
|
||||
return principal;
|
||||
}
|
||||
} catch (IOException|HttpException|DavException e) {
|
||||
Constants.log.debug("Service discovery failed", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -318,89 +415,24 @@ public class DavResourceFinder {
|
||||
* @param url URL to query with PROPFIND (Depth: 0)
|
||||
* @return current-user-principal URL, or null if none
|
||||
*/
|
||||
protected HttpUrl getCurrentUserPrincipal(HttpUrl url) throws IOException, HttpException, DavException {
|
||||
protected HttpUrl getCurrentUserPrincipal(HttpUrl url) {
|
||||
try {
|
||||
DavResource dav = new DavResource(httpClient, url);
|
||||
dav.propfind(0, CurrentUserPrincipal.NAME);
|
||||
CurrentUserPrincipal currentUserPrincipal = (CurrentUserPrincipal) dav.properties.get(CurrentUserPrincipal.NAME);
|
||||
if (currentUserPrincipal != null && currentUserPrincipal.href != null)
|
||||
return url.resolve(currentUserPrincipal.href);
|
||||
if (currentUserPrincipal != null && currentUserPrincipal.href != null) {
|
||||
HttpUrl principal = url.resolve(currentUserPrincipal.href);
|
||||
if (principal != null) {
|
||||
Constants.log.info("Found current-user-principal: " + principal);
|
||||
return principal;
|
||||
}
|
||||
}
|
||||
} catch(IOException|HttpException|DavException e) {
|
||||
Constants.log.debug("PROPFIND for current-user-principal on " + url + " failed", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the initial service URL from a given base URI (HTTP[S] or mailto URI, user name, password)
|
||||
* @param serverInfo User-given service information (including base URI, i.e. HTTP[S] URL+user name+password or mailto URI and password)
|
||||
* @param serviceName Service name ("carddav" or "caldav")
|
||||
* @return Initial service URL (HTTP/HTTPS), without user credentials
|
||||
* @throws URISyntaxException when the user-given URI is invalid
|
||||
*/
|
||||
public HttpUrl getInitialContextURL(ServerInfo serverInfo, String serviceName) throws URISyntaxException {
|
||||
String scheme,
|
||||
domain;
|
||||
int port = -1;
|
||||
String path = "/";
|
||||
|
||||
URI baseURI = serverInfo.getBaseURI();
|
||||
if ("mailto".equalsIgnoreCase(baseURI.getScheme())) {
|
||||
// mailto URIs
|
||||
String mailbox = serverInfo.getBaseURI().getSchemeSpecificPart();
|
||||
|
||||
// determine service FQDN
|
||||
int pos = mailbox.lastIndexOf("@");
|
||||
if (pos == -1)
|
||||
throw new URISyntaxException(mailbox, "Missing @ sign");
|
||||
|
||||
scheme = "https";
|
||||
domain = mailbox.substring(pos + 1);
|
||||
if (domain.isEmpty())
|
||||
throw new URISyntaxException(mailbox, "Missing domain name");
|
||||
} else {
|
||||
// HTTP(S) URLs
|
||||
scheme = baseURI.getScheme();
|
||||
domain = baseURI.getHost();
|
||||
port = baseURI.getPort();
|
||||
path = baseURI.getPath();
|
||||
}
|
||||
|
||||
// try to determine FQDN and port number using SRV records
|
||||
try {
|
||||
String name = "_" + serviceName + "._tcp." + domain;
|
||||
Constants.log.debug("Looking up SRV records for " + name);
|
||||
Record[] records = new Lookup(name, Type.SRV).run();
|
||||
if (records != null && records.length >= 1) {
|
||||
SRVRecord srv = selectSRVRecord(records);
|
||||
|
||||
scheme = "https";
|
||||
domain = srv.getTarget().toString(true);
|
||||
port = srv.getPort();
|
||||
Log.d(TAG, "Found " + serviceName + " service for " + domain + " -> " + domain + ":" + port);
|
||||
|
||||
// SRV record found, look for TXT record too (for initial context path)
|
||||
records = new Lookup(name, Type.TXT).run();
|
||||
if (records != null && records.length >= 1) {
|
||||
TXTRecord txt = (TXTRecord)records[0];
|
||||
for (Object o : txt.getStrings().toArray()) {
|
||||
String segment = (String)o;
|
||||
if (segment.startsWith("path=")) {
|
||||
path = segment.substring(5);
|
||||
Constants.log.debug("Found initial context path for " + serviceName + " at " + domain + " -> " + path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (TextParseException e) {
|
||||
throw new URISyntaxException(domain, "Invalid domain name");
|
||||
}
|
||||
|
||||
HttpUrl.Builder builder = new HttpUrl.Builder().scheme(scheme).host(domain);
|
||||
if (port != -1)
|
||||
builder.port(port);
|
||||
if (TextUtils.isEmpty(path))
|
||||
path = "/";
|
||||
return builder.encodedPath(path).build();
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user