mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Telecharger un fichier depuis ajean.link rendait du JSON
Le proxy chiffre (relay_e2e.go) reemballe TOUTE reponse en JSON avant de
la renvoyer : une reponse non-JSON est enveloppee en chaine. Du binaire
n'y survit pas — les octets non-UTF8 sont massacres — et le navigateur
telechargeait l'enveloppe JSON a la place du fichier. Le probleme est
structurel, pas propre au telechargement : il touchait aussi l'export
Markdown de conversation, qui revenait entre guillemets, echappements
compris.
handleChatFile prend donc deux formes supplementaires : `meta=1` rend une
fiche {name, size, e2e}, et `b64=1&offset=&len=` une tranche en base64.
Le client demande la fiche d'abord — minuscule, elle passe partout — et
en tire le transport a utiliser : binaire direct en local, tranches
base64 derriere le tunnel. Le client ne peut pas le deviner autrement, le
proxy lui rendant des reponses JSON parfaitement ordinaires.
Le decoupage en tranches de 8 Mo evite de tenir le fichier entier en
memoire pour le transporter, comme a l'envoi. Verifie sur 120 Mo : les
deux chemins rendent exactement les memes 125 829 120 octets, et le test
Go compare octet a octet sur des donnees non-UTF8, celles que le
reemballage JSON detruisait.
relay_e2e.go marque desormais les requetes venues du tunnel
(X-Ajean-E2E) : c'est le seul moyen pour un handler de savoir que sa
reponse sera reemballee.
This commit is contained in:
1 parent
e07051e1e3
commit
78423276e7
6 files changed
+261
-22
No files matched your search
@@ -232,6 +232,11 @@ func handleE2EReq(w http.ResponseWriter, r *http.Request, inner http.Handler) {
|
||||
}
|
||||
ir := httptest.NewRequest(method, req.Path, bodyReader)
|
||||
ir.Header.Set("Content-Type", "application/json")
|
||||
// Marque la requête comme arrivée PAR LE TUNNEL. Tout ce qui ressort d'ici est
|
||||
// réemballé en JSON (voir plus bas) : un handler qui renvoie du binaire — le
|
||||
// téléchargement d'un fichier — doit le savoir pour proposer une forme qui
|
||||
// traverse, sans quoi le client reçoit l'enveloppe JSON à la place du fichier.
|
||||
ir.Header.Set(e2eInnerHeader, "1")
|
||||
rec := httptest.NewRecorder()
|
||||
inner.ServeHTTP(rec, ir)
|
||||
respBody := rec.Body.Bytes()
|
||||
|
||||
@@ -2713,13 +2713,21 @@ async function downloadExport(url){
|
||||
try{
|
||||
const r = await jfetch(url);
|
||||
if(!r.ok){ toast('erreur : HTTP ' + r.status); return; }
|
||||
const blob = await r.blob();
|
||||
// Derrière app.ajean.link, le proxy chiffré réemballe TOUTE réponse en JSON :
|
||||
// un export Markdown revenait donc comme une chaîne JSON entre guillemets,
|
||||
// échappements compris. On la déballe. En local, rien à faire — le type de
|
||||
// contenu n'est pas du JSON, et un export JSON est déjà à sa place.
|
||||
let text = await r.text();
|
||||
if((r.headers.get('Content-Type')||'').includes('json')){
|
||||
try{ const v = JSON.parse(text); if(typeof v === 'string') text = v; }catch(_){}
|
||||
}
|
||||
const blob = new Blob([text]);
|
||||
// Nom du fichier : celui proposé par le serveur (horodaté), à défaut un nom
|
||||
// local déduit du type renvoyé.
|
||||
// déduit du format DEMANDÉ — le type renvoyé ne dit plus rien derrière le
|
||||
// tunnel, où tout arrive en application/json.
|
||||
const cd = r.headers.get('Content-Disposition') || '';
|
||||
const m = cd.match(/filename="([^"]+)"/);
|
||||
const name = m ? m[1]
|
||||
: 'ajean-conversation.' + ((r.headers.get('Content-Type')||'').includes('json') ? 'json' : 'md');
|
||||
const name = m ? m[1] : 'ajean-conversation.' + (url.includes('format=json') ? 'json' : 'md');
|
||||
// ⚠️ blobURL, surtout pas `url` : ce nom est déjà celui du paramètre, et le
|
||||
// redéclarer ici mettrait la ligne `jfetch(url)` ci-dessus dans la zone morte
|
||||
// du const — l'export échouerait avant même de partir.
|
||||
@@ -5089,23 +5097,62 @@ function hasMsgFiles(el){
|
||||
// Un simple href ne suffirait pas : la clé de pilotage voyage dans un en-tête
|
||||
// Authorization que le navigateur ne mettrait pas sur une navigation, et le
|
||||
// chemin de base change derrière le tunnel (/u/<id>). D'où le fetch + blob.
|
||||
const DL_CHUNK = 8*1024*1024; // aligné sur downloadChunkMax (web_upload.go)
|
||||
// Décode une tranche base64 en octets.
|
||||
function b64ToBytes(s){
|
||||
const bin=atob(s), out=new Uint8Array(bin.length);
|
||||
for(let i=0;i<bin.length;i++) out[i]=bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
// Récupère le fichier en tranches base64. C'est la voie OBLIGATOIRE derrière
|
||||
// app.ajean.link : le proxy chiffré réemballe toute réponse en JSON, et du
|
||||
// binaire n'y survit pas — on téléchargeait l'enveloppe JSON à la place du
|
||||
// fichier. Le base64, lui, traverse intact.
|
||||
async function fetchFileB64(path, size, chip){
|
||||
const parts=[]; let off=0;
|
||||
do{
|
||||
const r=await jfetch('/api/chat/file?b64=1&len='+DL_CHUNK+'&offset='+off+'&path='+encodeURIComponent(path));
|
||||
const j=await r.json();
|
||||
if(!r.ok || !j.ok) throw new Error(j.error||('HTTP '+r.status));
|
||||
parts.push(b64ToBytes(j.data||''));
|
||||
off=(j.offset||0)+(parts[parts.length-1].length);
|
||||
if(chip && size) chip.title='téléchargement '+Math.round(off*100/size)+' %';
|
||||
if(j.eof) break;
|
||||
} while(off<size);
|
||||
return new Blob(parts);
|
||||
}
|
||||
async function downloadWorkspaceFile(path, name, a){
|
||||
if(a) a.classList.add('busy');
|
||||
const wasTitle=a?a.title:'';
|
||||
try{
|
||||
const r=await jfetch('/api/chat/file?path='+encodeURIComponent(path));
|
||||
if(!r.ok){
|
||||
let m='HTTP '+r.status; try{ m=(await r.json()).error||m; }catch(_){}
|
||||
toast('téléchargement impossible : '+m); return;
|
||||
// La fiche d'abord : elle est minuscule, elle passe partout, et elle dit si
|
||||
// on est derrière le tunnel — ce que le client ne peut pas deviner seul, le
|
||||
// proxy lui rendant des réponses JSON parfaitement ordinaires.
|
||||
const m=await jfetch('/api/chat/file?meta=1&path='+encodeURIComponent(path));
|
||||
const meta=await m.json().catch(()=>({}));
|
||||
if(!m.ok || !meta.ok){
|
||||
toast('téléchargement impossible : '+(meta.error||('HTTP '+m.status))); return;
|
||||
}
|
||||
const blobURL=URL.createObjectURL(await r.blob());
|
||||
let blob;
|
||||
if(meta.e2e){
|
||||
blob=await fetchFileB64(path, meta.size||0, a);
|
||||
} else {
|
||||
const r=await jfetch('/api/chat/file?path='+encodeURIComponent(path));
|
||||
if(!r.ok){
|
||||
let msg='HTTP '+r.status; try{ msg=(await r.json()).error||msg; }catch(_){}
|
||||
toast('téléchargement impossible : '+msg); return;
|
||||
}
|
||||
blob=await r.blob();
|
||||
}
|
||||
const blobURL=URL.createObjectURL(blob);
|
||||
const link=document.createElement('a');
|
||||
link.href=blobURL; link.download=name||'fichier';
|
||||
link.href=blobURL; link.download=name||meta.name||'fichier';
|
||||
document.body.appendChild(link); link.click(); link.remove();
|
||||
// Révocation différée : Safari annule le téléchargement si l'URL disparaît
|
||||
// dans la foulée du clic.
|
||||
setTimeout(()=>URL.revokeObjectURL(blobURL), 10000);
|
||||
}catch(e){ toast('téléchargement impossible : '+((e&&e.message)||'erreur')); }
|
||||
finally{ if(a) a.classList.remove('busy'); }
|
||||
finally{ if(a){ a.classList.remove('busy'); a.title=wasTitle; } }
|
||||
}
|
||||
// Markdown refuse les espaces NON échappés dans la cible d'un lien : le modèle
|
||||
// écrit [le rapport](mon rapport.pdf), et rien n'est rendu du tout — même pas un
|
||||
|
||||
@@ -353,13 +353,21 @@ async function downloadExport(url){
|
||||
try{
|
||||
const r = await jfetch(url);
|
||||
if(!r.ok){ toast('erreur : HTTP ' + r.status); return; }
|
||||
const blob = await r.blob();
|
||||
// Derrière app.ajean.link, le proxy chiffré réemballe TOUTE réponse en JSON :
|
||||
// un export Markdown revenait donc comme une chaîne JSON entre guillemets,
|
||||
// échappements compris. On la déballe. En local, rien à faire — le type de
|
||||
// contenu n'est pas du JSON, et un export JSON est déjà à sa place.
|
||||
let text = await r.text();
|
||||
if((r.headers.get('Content-Type')||'').includes('json')){
|
||||
try{ const v = JSON.parse(text); if(typeof v === 'string') text = v; }catch(_){}
|
||||
}
|
||||
const blob = new Blob([text]);
|
||||
// Nom du fichier : celui proposé par le serveur (horodaté), à défaut un nom
|
||||
// local déduit du type renvoyé.
|
||||
// déduit du format DEMANDÉ — le type renvoyé ne dit plus rien derrière le
|
||||
// tunnel, où tout arrive en application/json.
|
||||
const cd = r.headers.get('Content-Disposition') || '';
|
||||
const m = cd.match(/filename="([^"]+)"/);
|
||||
const name = m ? m[1]
|
||||
: 'ajean-conversation.' + ((r.headers.get('Content-Type')||'').includes('json') ? 'json' : 'md');
|
||||
const name = m ? m[1] : 'ajean-conversation.' + (url.includes('format=json') ? 'json' : 'md');
|
||||
// ⚠️ blobURL, surtout pas `url` : ce nom est déjà celui du paramètre, et le
|
||||
// redéclarer ici mettrait la ligne `jfetch(url)` ci-dessus dans la zone morte
|
||||
// du const — l'export échouerait avant même de partir.
|
||||
|
||||
@@ -76,23 +76,62 @@ function hasMsgFiles(el){
|
||||
// Un simple href ne suffirait pas : la clé de pilotage voyage dans un en-tête
|
||||
// Authorization que le navigateur ne mettrait pas sur une navigation, et le
|
||||
// chemin de base change derrière le tunnel (/u/<id>). D'où le fetch + blob.
|
||||
const DL_CHUNK = 8*1024*1024; // aligné sur downloadChunkMax (web_upload.go)
|
||||
// Décode une tranche base64 en octets.
|
||||
function b64ToBytes(s){
|
||||
const bin=atob(s), out=new Uint8Array(bin.length);
|
||||
for(let i=0;i<bin.length;i++) out[i]=bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
// Récupère le fichier en tranches base64. C'est la voie OBLIGATOIRE derrière
|
||||
// app.ajean.link : le proxy chiffré réemballe toute réponse en JSON, et du
|
||||
// binaire n'y survit pas — on téléchargeait l'enveloppe JSON à la place du
|
||||
// fichier. Le base64, lui, traverse intact.
|
||||
async function fetchFileB64(path, size, chip){
|
||||
const parts=[]; let off=0;
|
||||
do{
|
||||
const r=await jfetch('/api/chat/file?b64=1&len='+DL_CHUNK+'&offset='+off+'&path='+encodeURIComponent(path));
|
||||
const j=await r.json();
|
||||
if(!r.ok || !j.ok) throw new Error(j.error||('HTTP '+r.status));
|
||||
parts.push(b64ToBytes(j.data||''));
|
||||
off=(j.offset||0)+(parts[parts.length-1].length);
|
||||
if(chip && size) chip.title='téléchargement '+Math.round(off*100/size)+' %';
|
||||
if(j.eof) break;
|
||||
} while(off<size);
|
||||
return new Blob(parts);
|
||||
}
|
||||
async function downloadWorkspaceFile(path, name, a){
|
||||
if(a) a.classList.add('busy');
|
||||
const wasTitle=a?a.title:'';
|
||||
try{
|
||||
const r=await jfetch('/api/chat/file?path='+encodeURIComponent(path));
|
||||
if(!r.ok){
|
||||
let m='HTTP '+r.status; try{ m=(await r.json()).error||m; }catch(_){}
|
||||
toast('téléchargement impossible : '+m); return;
|
||||
// La fiche d'abord : elle est minuscule, elle passe partout, et elle dit si
|
||||
// on est derrière le tunnel — ce que le client ne peut pas deviner seul, le
|
||||
// proxy lui rendant des réponses JSON parfaitement ordinaires.
|
||||
const m=await jfetch('/api/chat/file?meta=1&path='+encodeURIComponent(path));
|
||||
const meta=await m.json().catch(()=>({}));
|
||||
if(!m.ok || !meta.ok){
|
||||
toast('téléchargement impossible : '+(meta.error||('HTTP '+m.status))); return;
|
||||
}
|
||||
const blobURL=URL.createObjectURL(await r.blob());
|
||||
let blob;
|
||||
if(meta.e2e){
|
||||
blob=await fetchFileB64(path, meta.size||0, a);
|
||||
} else {
|
||||
const r=await jfetch('/api/chat/file?path='+encodeURIComponent(path));
|
||||
if(!r.ok){
|
||||
let msg='HTTP '+r.status; try{ msg=(await r.json()).error||msg; }catch(_){}
|
||||
toast('téléchargement impossible : '+msg); return;
|
||||
}
|
||||
blob=await r.blob();
|
||||
}
|
||||
const blobURL=URL.createObjectURL(blob);
|
||||
const link=document.createElement('a');
|
||||
link.href=blobURL; link.download=name||'fichier';
|
||||
link.href=blobURL; link.download=name||meta.name||'fichier';
|
||||
document.body.appendChild(link); link.click(); link.remove();
|
||||
// Révocation différée : Safari annule le téléchargement si l'URL disparaît
|
||||
// dans la foulée du clic.
|
||||
setTimeout(()=>URL.revokeObjectURL(blobURL), 10000);
|
||||
}catch(e){ toast('téléchargement impossible : '+((e&&e.message)||'erreur')); }
|
||||
finally{ if(a) a.classList.remove('busy'); }
|
||||
finally{ if(a){ a.classList.remove('busy'); a.title=wasTitle; } }
|
||||
}
|
||||
// Markdown refuse les espaces NON échappés dans la cible d'un lien : le modèle
|
||||
// écrit [le rapport](mon rapport.pdf), et rien n'est rendu du tout — même pas un
|
||||
|
||||
@@ -14,10 +14,12 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -34,6 +36,11 @@ const (
|
||||
// 8 Mo décodés ≈ 11 Mo de base64. C'est aussi la taille demandée au client ;
|
||||
// un morceau plus gros est refusé plutôt que d'être avalé.
|
||||
uploadChunkMax = 8 << 20
|
||||
|
||||
// downloadChunkMax borne UNE tranche de téléchargement encodée en base64 (voir
|
||||
// handleChatFile). Même logique que pour l'envoi : c'est la mémoire du process
|
||||
// qu'on protège, pas la taille du fichier.
|
||||
downloadChunkMax = 8 << 20
|
||||
)
|
||||
|
||||
// uploadsDir renvoie (en le créant) le dossier de dépôt, dans le workspace agent.
|
||||
@@ -172,8 +179,25 @@ func workspaceRel(abs string) (string, bool) {
|
||||
return filepath.ToSlash(rel), true
|
||||
}
|
||||
|
||||
// e2eInnerHeader marque une requête dispatchée depuis le proxy chiffré
|
||||
// (relay_e2e.go). Cf. handleChatFile : c'est le seul moyen pour un handler de
|
||||
// savoir que sa réponse sera réemballée en JSON.
|
||||
const e2eInnerHeader = "X-Ajean-E2E"
|
||||
|
||||
// handleChatFile sert un fichier du dossier de travail, en pièce jointe. Le
|
||||
// client passe le chemin RELATIF que le modèle a écrit dans sa réponse.
|
||||
//
|
||||
// Trois formes de réponse, pour une raison de transport :
|
||||
// - par défaut, le fichier brut — le chemin direct, en local ou sur le LAN ;
|
||||
// - `meta=1`, une fiche {name, size, e2e} : le client y apprend s'il est
|
||||
// derrière le tunnel, donc quelle forme demander ensuite ;
|
||||
// - `b64=1&offset=&len=`, une tranche encodée en base64.
|
||||
//
|
||||
// La raison : à travers app.ajean.link, TOUTE réponse est réemballée en JSON par
|
||||
// le proxy chiffré. Du binaire n'y survit pas — les octets non-UTF8 sont
|
||||
// massacrés, et on téléchargeait une enveloppe JSON au lieu du fichier. Le
|
||||
// base64 traverse, et le découpage en tranches évite de tenir un gigaoctet en
|
||||
// mémoire pour le transporter.
|
||||
func handleChatFile(w http.ResponseWriter, r *http.Request) {
|
||||
rel := r.URL.Query().Get("path")
|
||||
if strings.TrimSpace(rel) == "" {
|
||||
@@ -193,6 +217,51 @@ func handleChatFile(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
name := filepath.Base(abs)
|
||||
q := r.URL.Query()
|
||||
if q.Get("meta") != "" {
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true, "name": name, "size": st.Size(),
|
||||
// Le client ne peut pas deviner seul qu'il passe par le tunnel : le
|
||||
// proxy lui rend des réponses JSON parfaitement ordinaires.
|
||||
"e2e": r.Header.Get(e2eInnerHeader) != "",
|
||||
})
|
||||
return
|
||||
}
|
||||
if q.Get("b64") != "" {
|
||||
off, _ := strconv.ParseInt(q.Get("offset"), 10, 64)
|
||||
length, _ := strconv.ParseInt(q.Get("len"), 10, 64)
|
||||
if length <= 0 || length > downloadChunkMax {
|
||||
length = downloadChunkMax
|
||||
}
|
||||
if off < 0 || off > st.Size() {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "position hors du fichier"})
|
||||
return
|
||||
}
|
||||
if off+length > st.Size() {
|
||||
length = st.Size() - off
|
||||
}
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
buf := make([]byte, length)
|
||||
// ReadAt : positionne et lit en une fois, et remplit tout le tampon (ce
|
||||
// qu'un simple Read ne garantit pas). io.EOF sur la dernière tranche est
|
||||
// normal, pas une erreur.
|
||||
n, err := f.ReadAt(buf, off)
|
||||
if err != nil && err != io.EOF {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true, "name": name, "size": st.Size(), "offset": off,
|
||||
"data": base64.StdEncoding.EncodeToString(buf[:n]),
|
||||
"eof": off+int64(n) >= st.Size(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// Toujours en TÉLÉCHARGEMENT, jamais rendu : un .html écrit par le modèle ne
|
||||
// doit pas s'exécuter dans l'origine de l'UI (il y lirait la clé de pilotage).
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package ajean
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -231,3 +233,72 @@ func TestChatUploadUnknownSessionRefused(t *testing.T) {
|
||||
t.Fatalf("code %d, attendu 409 — resp %+v", code, resp)
|
||||
}
|
||||
}
|
||||
|
||||
// Le téléchargement via app.ajean.link : le proxy chiffré réemballe toute
|
||||
// réponse en JSON, où du binaire ne survit pas. Le handler doit donc annoncer
|
||||
// qu'on est derrière le tunnel, puis savoir servir des tranches base64.
|
||||
func TestChatFileB64ForE2E(t *testing.T) {
|
||||
dir, _ := uploadsDir()
|
||||
// Des octets NON-UTF8 : c'est exactement ce que le réemballage JSON massacre.
|
||||
raw := make([]byte, 5000)
|
||||
for i := range raw {
|
||||
raw[i] = byte(i % 256)
|
||||
}
|
||||
p := filepath.Join(dir, "binaire.bin")
|
||||
if err := os.WriteFile(p, raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Remove(p)
|
||||
|
||||
// La fiche dit « tunnel » quand la requête vient du proxy, et pas autrement.
|
||||
meta := func(viaTunnel bool) map[string]any {
|
||||
req := httptest.NewRequest("GET", "/api/chat/file?meta=1&path=uploads/binaire.bin", nil)
|
||||
if viaTunnel {
|
||||
req.Header.Set(e2eInnerHeader, "1")
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handleChatFile(rec, req)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &m)
|
||||
return m
|
||||
}
|
||||
if m := meta(true); m["e2e"] != true || m["size"].(float64) != 5000 {
|
||||
t.Fatalf("fiche via tunnel : %+v", m)
|
||||
}
|
||||
if m := meta(false); m["e2e"] != false {
|
||||
t.Fatalf("fiche en local annoncée comme tunnel : %+v", m)
|
||||
}
|
||||
|
||||
// Reconstitution par tranches : l'octet doit revenir intact.
|
||||
var got []byte
|
||||
for off, guard := 0, 0; ; guard++ {
|
||||
if guard > 50 {
|
||||
t.Fatal("boucle de tranches sans fin")
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handleChatFile(rec, httptest.NewRequest("GET",
|
||||
fmt.Sprintf("/api/chat/file?b64=1&len=1024&offset=%d&path=uploads/binaire.bin", off), nil))
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("tranche à %d : code %d — %s", off, rec.Code, rec.Body.String())
|
||||
}
|
||||
var j struct {
|
||||
Data string `json:"data"`
|
||||
EOF bool `json:"eof"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &j); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(j.Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = append(got, b...)
|
||||
off += len(b)
|
||||
if j.EOF {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !bytes.Equal(got, raw) {
|
||||
t.Fatalf("fichier reconstitué : %d octets, attendu %d", len(got), len(raw))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user