1212import java .io .ByteArrayOutputStream ;
1313import java .io .IOException ;
1414import java .nio .charset .StandardCharsets ;
15+ import java .time .Instant ;
1516import java .util .ArrayList ;
17+ import java .util .Arrays ;
1618import java .util .Base64 ;
1719import java .util .BitSet ;
1820import java .util .Collection ;
21+ import java .util .Collections ;
1922import java .util .EnumSet ;
2023import java .util .HashMap ;
2124import java .util .HashSet ;
2831import java .util .stream .Collectors ;
2932
3033import smithereen .ApplicationContext ;
34+ import smithereen .Config ;
35+ import smithereen .Utils ;
3136import smithereen .activitypub .objects .Actor ;
3237import smithereen .activitypub .objects .LocalImage ;
3338import smithereen .api .ApiCallContext ;
39+ import smithereen .api .ApiErrorException ;
40+ import smithereen .api .model .ApiCaptchaError ;
3441import smithereen .api .model .ApiComment ;
3542import smithereen .api .model .ApiErrorType ;
3643import smithereen .api .model .ApiGroup ;
6774import smithereen .model .viewmodel .CommentViewModel ;
6875import smithereen .model .viewmodel .PostViewModel ;
6976import smithereen .text .FormattedTextFormat ;
77+ import smithereen .util .ByteArrayMapKey ;
78+ import smithereen .util .CaptchaGenerator ;
7079import smithereen .util .CryptoUtils ;
7180import smithereen .util .JsonObjectBuilder ;
7281import smithereen .util .UriBuilder ;
@@ -78,6 +87,7 @@ public class ApiUtils{
7887 public static final byte [] EXTERNAL_MEDIA_KEY =CryptoUtils .randomBytes (16 );
7988 public static final HashMap <Integer , Long > uploadUrlIDs =new HashMap <>();
8089 public static final AtomicInteger lastUploadUrlID =new AtomicInteger ();
90+ private static final HashMap <ByteArrayMapKey , CaptchaInfo > captchas =new HashMap <>();
8191
8292 @ NotNull
8393 @ Unmodifiable
@@ -723,11 +733,14 @@ public static String getUploadURL(ApiCallContext actx, String path, Map<String,
723733 .toString ();
724734 }
725735
726- public static void removeOldUploadUrlIDs (){
736+ public static void doCleanupTasks (){
737+ long now =System .currentTimeMillis ();
727738 synchronized (uploadUrlIDs ){
728- long now =System .currentTimeMillis ();
729739 uploadUrlIDs .values ().removeIf (v ->now -v >60_000 );
730740 }
741+ synchronized (captchas ){
742+ captchas .values ().removeIf (ci ->now -ci .requestedAt .toEpochMilli ()>600_000 );
743+ }
731744 }
732745
733746 public static @ NotNull FormattedTextFormat getTextFormat (@ NotNull ApiCallContext actx ){
@@ -772,6 +785,61 @@ public static String getExternalMediaHash(Map<String, String> params){
772785 return Base64 .getUrlEncoder ().withoutPadding ().encodeToString (CryptoUtils .sha256 (buf .toByteArray ()));
773786 }
774787
775- public record InputAttachments (@ NotNull List <String > ids , @ NotNull Map <String , String > altTexts , @ Nullable Poll poll ){
788+ public static void enforceCaptcha (ApplicationContext ctx , ApiCallContext actx ){
789+ if (actx .self ==null )
790+ throw new IllegalArgumentException ("This method requires an account" );
791+ List <String > paramsHashSource =new ArrayList <>();
792+ actx .params .forEach ((k , v )->{
793+ if (!"captcha_sid" .equals (k ) && !"captcha_answer" .equals (k ))
794+ paramsHashSource .add (k +"=" +v );
795+ });
796+ Collections .sort (paramsHashSource );
797+ byte [] paramsHash =CryptoUtils .sha256 (String .join ("\n " , paramsHashSource ).getBytes (StandardCharsets .UTF_8 ));
798+ if (actx .hasParam ("captcha_sid" ) && actx .hasParam ("captcha_answer" )){
799+ byte [] sid =Utils .tryDecodeBase64Url (actx .requireParamString ("captcha_sid" ));
800+ if (sid !=null && sid .length ==16 ){
801+ CaptchaInfo info ;
802+ ByteArrayMapKey key =new ByteArrayMapKey (sid );
803+ synchronized (captchas ){
804+ info =captchas .remove (key );
805+ }
806+ String answer =actx .requireParamString ("captcha_answer" );
807+ if (info !=null && Arrays .equals (info .accessToken , actx .token .id ()) && Arrays .equals (info .paramsHash , paramsHash ) && answer .equals (info .answer )){
808+ long now =System .currentTimeMillis ();
809+ if (now -info .generatedAt .toEpochMilli ()>1500 && now -info .requestedAt .toEpochMilli ()<600_000 )
810+ return ;
811+ }
812+ }
813+ }
814+
815+ byte [] sid =CryptoUtils .randomBytes (16 );
816+ synchronized (captchas ){
817+ captchas .put (new ByteArrayMapKey (sid ), new CaptchaInfo (actx .token .id (), paramsHash , Instant .now (), null , null ));
818+ }
819+ ApiCaptchaError error =new ApiCaptchaError (ApiErrorType .CAPTCHA_NEEDED , null , actx .params );
820+ String encodedSid =Base64 .getUrlEncoder ().withoutPadding ().encodeToString (sid );
821+ error .captcha =new ApiCaptchaError .Captcha (Config .localURI ("/api/captcha/" +encodedSid +".png" ).toString (), CaptchaGenerator .IMG_WIDTH , CaptchaGenerator .IMG_HEIGHT , encodedSid , actx .lang .get ("captcha_hint" ));
822+ throw new ApiErrorException (error );
776823 }
824+
825+ public static CaptchaInfo getCaptchaInfo (byte [] sid ){
826+ CaptchaInfo info =captchas .get (new ByteArrayMapKey (sid ));
827+ if (info ==null || System .currentTimeMillis ()-info .requestedAt .toEpochMilli ()>600_000 )
828+ return null ;
829+ return info ;
830+ }
831+
832+ public static void updateCaptchaInfo (byte [] sid , String answer ){
833+ ByteArrayMapKey key =new ByteArrayMapKey (sid );
834+ synchronized (captchas ){
835+ CaptchaInfo info =captchas .get (key );
836+ if (info ==null )
837+ return ;
838+ captchas .put (key , new CaptchaInfo (info .accessToken , info .paramsHash , info .requestedAt , Instant .now (), answer ));
839+ }
840+ }
841+
842+ public record InputAttachments (@ NotNull List <String > ids , @ NotNull Map <String , String > altTexts , @ Nullable Poll poll ){}
843+
844+ public record CaptchaInfo (byte [] accessToken , byte [] paramsHash , Instant requestedAt , Instant generatedAt , String answer ){}
777845}
0 commit comments