From c576c003716851863c832b5e0dacb9186516f167 Mon Sep 17 00:00:00 2001 From: Vitaly Takmazov Date: Wed, 19 Jul 2023 23:50:14 +0300 Subject: Migrate to Twitter API 2.0 --- .../com/github/scribejava/apis/TwitterApi20.java | 37 ++++++++ src/main/java/com/juick/TwitterManager.java | 41 ++------ .../java/com/juick/model/ext/twitter/User.java | 28 ------ .../java/com/juick/service/TwitterService.java | 105 +++++++++++++++++++++ src/main/java/com/juick/service/UserService.java | 6 +- .../java/com/juick/service/UserServiceImpl.java | 24 ++++- src/main/java/com/juick/www/controllers/Site.java | 1 + .../com/juick/www/controllers/SocialLogin.java | 89 +++++++++-------- .../db/migration/V1.48__twitter_refresh_token.sql | 1 + src/main/resources/schema-h2.sql | 1 + src/main/resources/schema-mysql.sql | 1 + src/main/resources/schema-sqlite.sql | 1 + src/main/resources/schema-sqlserver.sql | 1 + src/main/resources/templates/views/post.html | 3 + 14 files changed, 225 insertions(+), 114 deletions(-) create mode 100644 src/main/java/com/github/scribejava/apis/TwitterApi20.java delete mode 100644 src/main/java/com/juick/model/ext/twitter/User.java create mode 100644 src/main/java/com/juick/service/TwitterService.java create mode 100644 src/main/resources/db/migration/V1.48__twitter_refresh_token.sql (limited to 'src') diff --git a/src/main/java/com/github/scribejava/apis/TwitterApi20.java b/src/main/java/com/github/scribejava/apis/TwitterApi20.java new file mode 100644 index 00000000..c34a246e --- /dev/null +++ b/src/main/java/com/github/scribejava/apis/TwitterApi20.java @@ -0,0 +1,37 @@ +package com.github.scribejava.apis; + +import com.github.scribejava.apis.openid.OpenIdJsonTokenExtractor; +import com.github.scribejava.core.builder.api.DefaultApi20; +import com.github.scribejava.core.extractors.TokenExtractor; +import com.github.scribejava.core.model.OAuth2AccessToken; + +public class TwitterApi20 extends DefaultApi20 { + private static final String AUTHORIZE_URL = "https://twitter.com/i/oauth2/authorize"; + private static final String ACCESS_TOKEN_RESOURCE = "https://api.twitter.com/2/oauth2/token"; + + protected TwitterApi20() { + } + + public static TwitterApi20 instance() { + return TwitterApi20.InstanceHolder.INSTANCE; + } + + public String getAccessTokenEndpoint() { + return ACCESS_TOKEN_RESOURCE; + } + + public String getAuthorizationBaseUrl() { + return AUTHORIZE_URL; + } + + public TokenExtractor getAccessTokenExtractor() { + return OpenIdJsonTokenExtractor.instance(); + } + private static class InstanceHolder { + private static final TwitterApi20 INSTANCE = new TwitterApi20(); + + private InstanceHolder() { + } + } +} + diff --git a/src/main/java/com/juick/TwitterManager.java b/src/main/java/com/juick/TwitterManager.java index f4cab89b..7a01debd 100644 --- a/src/main/java/com/juick/TwitterManager.java +++ b/src/main/java/com/juick/TwitterManager.java @@ -16,18 +16,15 @@ */ package com.juick; -import com.juick.model.Message; -import com.juick.www.api.SystemActivity; +import com.juick.service.TwitterService; import com.juick.service.UserService; -import com.juick.service.component.*; +import com.juick.service.component.NotificationListener; +import com.juick.service.component.PingEvent; +import com.juick.service.component.SystemEvent; import com.juick.util.MessageUtils; import com.juick.util.TagUtils; - +import com.juick.www.api.SystemActivity; import org.apache.commons.lang3.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import twitter4j.Twitter; import javax.inject.Inject; @@ -36,33 +33,11 @@ import javax.inject.Inject; */ public class TwitterManager implements NotificationListener { - private static final Logger logger = LoggerFactory.getLogger(TwitterManager.class); - @Inject private UserService userService; - @Value("${twitter_consumer_key:12345678}") - private String twitter_consumer_key; - @Value("${twitter_consumer_secret:secret}") - private String twitter_consumer_secret; - - void twitterPost(final Message jmsg) { - userService.getTwitterToken(jmsg.getUser().getUid()).ifPresent(t -> { - String status = MessageUtils.getMessageHashTags(jmsg) + StringUtils.defaultString(jmsg.getText()); - if (status.length() > 253) { - status = status.substring(0, 252) + "…"; - } - status += " http://juick.com/" + jmsg.getMid(); - var twitter = Twitter.newBuilder() - .oAuthConsumer(twitter_consumer_key, twitter_consumer_secret) - .oAuthAccessToken(t.token(), t.secret()).build(); - try { - twitter.v1().tweets().updateStatus(status); - } catch (Exception e) { - logger.info("Twitter exception {}: {}", jmsg.getUser().getName(), e); - } - }); - } + @Inject + private TwitterService twitterService; @Override public void processSystemEvent(SystemEvent systemEvent) { @@ -74,7 +49,7 @@ public class TwitterManager implements NotificationListener { } if (StringUtils.isNotEmpty(userService.getTwitterName(msg.getUser().getUid()))) { if (TagUtils.hasNoTag(msg.getTags(), "notwitter")) { - twitterPost(msg); + twitterService.twitterPost(msg); } } } diff --git a/src/main/java/com/juick/model/ext/twitter/User.java b/src/main/java/com/juick/model/ext/twitter/User.java deleted file mode 100644 index 5a09556d..00000000 --- a/src/main/java/com/juick/model/ext/twitter/User.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (C) 2008-2022, Juick - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package com.juick.model.ext.twitter; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; - -/** - * Created by vitalyster on 28.11.2016. - */ -@JsonIgnoreProperties(ignoreUnknown = true) -public record User( @JsonProperty("screen_name") String screenName) { -} diff --git a/src/main/java/com/juick/service/TwitterService.java b/src/main/java/com/juick/service/TwitterService.java new file mode 100644 index 00000000..d0c105f2 --- /dev/null +++ b/src/main/java/com/juick/service/TwitterService.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2008-2023, Juick + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.juick.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.scribejava.apis.TwitterApi20; +import com.github.scribejava.core.builder.ServiceBuilder; +import com.github.scribejava.core.model.OAuthRequest; +import com.github.scribejava.core.model.Response; +import com.github.scribejava.core.model.Verb; +import com.github.scribejava.core.oauth.OAuth20Service; +import com.juick.model.Message; +import com.juick.util.MessageUtils; +import jakarta.annotation.PostConstruct; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; + +import javax.inject.Inject; +import java.io.IOException; +import java.util.concurrent.ExecutionException; + +@Service +public class TwitterService { + private static final Logger logger = LoggerFactory.getLogger("Twitter"); + @Value("${twitter_client_id:12345678}") + private String clientId; + @Value("${twitter_client_secret:secret}") + private String clientSecret; + private static final String redirectUri = "https://juick.com/_twitter"; + @Inject + private UserService userService; + private OAuth20Service twitterAuthService; + + @Inject + private ObjectMapper jsonMapper; + + @PostConstruct + public void init() { + ServiceBuilder twitterBuilder = new ServiceBuilder(clientId); + setTwitterAuthService(twitterBuilder.apiSecret(clientSecret) + .defaultScope("tweet.read tweet.write users.read offline.access") + .callback(redirectUri) + .build(TwitterApi20.instance())); + } + + public void twitterPost(final Message jmsg) { + userService.getTwitterToken(jmsg.getUser().getUid()).ifPresent(t -> { + String status = MessageUtils.getMessageHashTags(jmsg) + StringUtils.defaultString(jmsg.getText()); + if (status.length() > 253) { + status = status.substring(0, 252) + "…"; + } + status += " http://juick.com/" + jmsg.getMid(); + try { + var token = twitterAuthService.refreshAccessToken(t.secret()); + userService.refreshTwitterToken(jmsg.getUser(), token.getAccessToken(), token.getRefreshToken()); + OAuthRequest postRequest = new OAuthRequest(Verb.POST, + "https://api.twitter.com/2/tweets"); + var body = jsonMapper.createObjectNode(); + body.put("text", status); + postRequest.addHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE); + postRequest.setPayload(jsonMapper.writeValueAsString(body)); + twitterAuthService.signRequest(token, postRequest); + try (Response twitterResponse = twitterAuthService.execute(postRequest)) { + if (twitterResponse.isSuccessful()) { + logger.info(twitterResponse.getBody()); + } else { + logger.warn("Twitter error {}: {}", twitterResponse.getCode(), twitterResponse.getBody()); + } + } catch (Exception e) { + logger.error("Twitter exception {}", jmsg.getUser().getName(), e); + } + } catch (IOException | InterruptedException | ExecutionException e) { + logger.error("Twitter exception {}", jmsg.getUser().getName(), e); + } + }); + } + + public OAuth20Service getTwitterAuthService() { + return twitterAuthService; + } + + public void setTwitterAuthService(OAuth20Service twitterAuthService) { + this.twitterAuthService = twitterAuthService; + } +} diff --git a/src/main/java/com/juick/service/UserService.java b/src/main/java/com/juick/service/UserService.java index 4acc5b6a..31822b01 100644 --- a/src/main/java/com/juick/service/UserService.java +++ b/src/main/java/com/juick/service/UserService.java @@ -104,7 +104,11 @@ public interface UserService { List getUserIgnoredUsers(int uid); List getUserVipUsers(int uid); @CacheEvict(value = "twitter_user", key="{ #user.getUid() }") - boolean linkTwitterAccount(User user, String accessToken, String accessTokenSecret, String screenName); + boolean linkTwitterAccount(User user, String accessToken, String refreshToken, String screenName); + + boolean refreshTwitterToken(User user, String accessToken, String refreshToken); + + boolean isTwitter1User(User user); int getStatsMessages(int uid); diff --git a/src/main/java/com/juick/service/UserServiceImpl.java b/src/main/java/com/juick/service/UserServiceImpl.java index d19af067..7f73e6d1 100644 --- a/src/main/java/com/juick/service/UserServiceImpl.java +++ b/src/main/java/com/juick/service/UserServiceImpl.java @@ -523,10 +523,24 @@ public class UserServiceImpl extends BaseJdbcService implements UserService { @Transactional @Override public boolean linkTwitterAccount( - final User user, final String accessToken, final String accessTokenSecret, final String screenName) { - return getJdbcTemplate().update("INSERT INTO twitter(user_id,access_token,access_token_secret,uname) " + - "VALUES (?,?,?,?)", - user.getUid(), accessToken, accessTokenSecret, screenName) > 0; + final User user, final String accessToken, final String refreshToken, final String screenName) { + return getJdbcTemplate().update("INSERT INTO twitter(user_id,access_token,access_token_secret,refresh_token,uname) " + + "VALUES (?,?,'',?,?)", + user.getUid(), accessToken, refreshToken, screenName) > 0; + } + + @Transactional + @Override + public boolean refreshTwitterToken( + final User user, final String accessToken, final String refreshToken) { + return getJdbcTemplate().update("UPDATE twitter SET access_token=?, refresh_token=?" + + " WHERE user_id=?", + accessToken, refreshToken, user.getUid()) > 0; + } + @Transactional(readOnly = true) + @Override + public boolean isTwitter1User(User user) { + return jdbcTemplate.queryForList("SELECT user_id FROM twitter WHERE user_id=? AND refresh_token=''", Integer.class, user.getUid()).size() > 0; } @Transactional(readOnly = true) @@ -612,7 +626,7 @@ public class UserServiceImpl extends BaseJdbcService implements UserService { @Override public Optional getTwitterToken(final int uid) { List list = getJdbcTemplate().query( - "SELECT uname, access_token, access_token_secret FROM twitter WHERE user_id = ? AND crosspost = true", + "SELECT uname, access_token, refresh_token FROM twitter WHERE user_id = ? AND crosspost = true", (rs, num) -> new ExternalToken(rs.getString(1), "twitter", rs.getString(2), rs.getString(3)), uid); diff --git a/src/main/java/com/juick/www/controllers/Site.java b/src/main/java/com/juick/www/controllers/Site.java index 8b35593c..47438900 100644 --- a/src/main/java/com/juick/www/controllers/Site.java +++ b/src/main/java/com/juick/www/controllers/Site.java @@ -95,6 +95,7 @@ public class Site { tagService.getUserTagStats(user.getUid()).stream() .sorted((e1, e2) -> Integer.compare(e2.getUsageCount(), e1.getUsageCount())).limit(20) .map(t -> t.getTag().getName()).toList()); + model.addAttribute("twitter1", userService.isTwitter1User(user)); } @GetMapping("/login") diff --git a/src/main/java/com/juick/www/controllers/SocialLogin.java b/src/main/java/com/juick/www/controllers/SocialLogin.java index 0cac26c5..bae9deb2 100644 --- a/src/main/java/com/juick/www/controllers/SocialLogin.java +++ b/src/main/java/com/juick/www/controllers/SocialLogin.java @@ -16,18 +16,19 @@ */ package com.juick.www.controllers; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.github.scribejava.apis.*; import com.github.scribejava.core.builder.ServiceBuilder; import com.github.scribejava.core.model.*; +import com.github.scribejava.core.oauth.AccessTokenRequestParams; +import com.github.scribejava.core.oauth.AuthorizationUrlBuilder; import com.github.scribejava.core.oauth.OAuth10aService; import com.github.scribejava.core.oauth.OAuth20Service; +import com.github.scribejava.core.pkce.PKCEService; import com.juick.model.ext.facebook.User; import com.juick.model.ext.vk.UsersResponse; -import com.juick.service.EmailService; -import com.juick.service.TelegramService; -import com.juick.service.UserService; -import com.juick.service.VKService; +import com.juick.service.*; import com.juick.service.security.entities.JuickUser; import com.juick.util.HttpBadRequestException; @@ -82,17 +83,16 @@ public class SocialLogin { private String FACEBOOK_SECRET; @Value("${ap_base_uri:http://localhost:8080/}") private String baseUri; - private static final String TWITTER_VERIFY_URL = "https://api.twitter.com/1.1/account/verify_credentials.json"; + private static final String TWITTER_VERIFY_URL = "https://api.twitter.com/2/users/me"; @Inject private ObjectMapper jsonMapper; private ServiceBuilder twitterBuilder; private OAuth20Service facebookAuthService, appleSignInService; @Inject private VKService vkService; - @Value("${twitter_consumer_key:appid}") - private String twitterConsumerKey; - @Value("${twitter_consumer_secret:secret}") - private String twitterConsumerSecret; + @Inject + private TwitterService twitterService; + @Value("${telegram_token:secret}") private String telegramToken; @Value("${apple_app_id:appid}") @@ -108,10 +108,11 @@ public class SocialLogin { @Inject private RememberMeServices rememberMeServices; + private AuthorizationUrlBuilder authorizationUrlBuilder; + @PostConstruct public void init() { ServiceBuilder facebookBuilder = new ServiceBuilder(FACEBOOK_APPID); - twitterBuilder = new ServiceBuilder(twitterConsumerKey); UriComponentsBuilder redirectBuilder = UriComponentsBuilder.fromUriString(baseUri); String facebookRedirectUri = redirectBuilder.replacePath("/_fblogin").build().toUriString(); @@ -189,47 +190,41 @@ public class SocialLogin { } @GetMapping("/_twitter") - protected void doTwitterLogin(com.juick.model.User user, HttpServletRequest request, - HttpServletResponse response) throws IOException, ExecutionException, InterruptedException { - String hash = StringUtils.EMPTY, request_token = StringUtils.EMPTY, request_token_secret = StringUtils.EMPTY; - String verifier = request.getParameter("oauth_verifier"); - Cookie[] cookies = request.getCookies(); - for (Cookie cookie : cookies) { - if (cookie.getName().equals("hash")) { - hash = cookie.getValue(); - } - if (cookie.getName().equals("request_token")) { - request_token = cookie.getValue(); - } - if (cookie.getName().equals("request_token_secret")) { - request_token_secret = cookie.getValue(); - } - } - OAuth10aService oAuthService = twitterBuilder.apiSecret(twitterConsumerSecret) - .callback("https://juick.com/_twitter").build(TwitterApi.instance()); + protected String doTwitterLogin(@RequestParam(required = false) String code, + @RequestParam(required = false) String state, + com.juick.model.User user, + HttpServletRequest request) + throws IOException, ExecutionException, InterruptedException { - if (request_token.isEmpty() && request_token_secret.isEmpty() && (verifier == null || verifier.isEmpty())) { - OAuth1RequestToken requestToken = oAuthService.getRequestToken(); - String authUrl = oAuthService.getAuthorizationUrl(requestToken); - response.addCookie(new Cookie("request_token", requestToken.getToken())); - response.addCookie(new Cookie("request_token_secret", requestToken.getTokenSecret())); - response.setStatus(HttpServletResponse.SC_FOUND); - response.setHeader("Location", authUrl); + if (StringUtils.isBlank(code)) { + state = UUID.randomUUID().toString(); + authorizationUrlBuilder = twitterService.getTwitterAuthService().createAuthorizationUrlBuilder() + .state(state) + .initPKCE(); + return "redirect:" + authorizationUrlBuilder.build(); } else { - if (verifier != null && verifier.length() > 0) { - OAuth1RequestToken requestToken = new OAuth1RequestToken(request_token, request_token_secret); - OAuth1AccessToken accessToken = oAuthService.getAccessToken(requestToken, verifier); - OAuthRequest oAuthRequest = new OAuthRequest(Verb.GET, TWITTER_VERIFY_URL); - oAuthService.signRequest(accessToken, oAuthRequest); - com.juick.model.ext.twitter.User twitterUser = jsonMapper.readValue( - oAuthService.execute(oAuthRequest).getBody(), com.juick.model.ext.twitter.User.class); - if (userService.linkTwitterAccount(user, accessToken.getToken(), accessToken.getTokenSecret(), - twitterUser.screenName())) { - response.setStatus(HttpServletResponse.SC_FOUND); - response.setHeader("Location", "http://juick.com/settings"); + var token = twitterService.getTwitterAuthService().getAccessToken(AccessTokenRequestParams.create(code) + .pkceCodeVerifier(authorizationUrlBuilder.getPkce().getCodeVerifier())); + var me = new OAuthRequest(Verb.GET, TWITTER_VERIFY_URL); + twitterService.getTwitterAuthService().signRequest(token, me); + try (var response = twitterService.getTwitterAuthService().execute(me)) { + if (response.isSuccessful()) { + logger.info("Twitter response: {}", response.getBody()); + JsonNode json = jsonMapper.readTree(response.getBody()); + var screenName = json.get("data").get("username").asText(); + if (userService.linkTwitterAccount(user, token.getAccessToken(), token.getRefreshToken(), + screenName)) { + return "redirect:https://juick.com/settings"; + } else { + throw new HttpBadRequestException(); + } } else { - response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + logger.warn("Twitter error {}: {}", response.getCode(), response.getBody()); + throw new HttpBadRequestException(); } + } catch (Exception e) { + logger.error("Twitter error", e); + throw new HttpBadRequestException(); } } } diff --git a/src/main/resources/db/migration/V1.48__twitter_refresh_token.sql b/src/main/resources/db/migration/V1.48__twitter_refresh_token.sql new file mode 100644 index 00000000..a01efb64 --- /dev/null +++ b/src/main/resources/db/migration/V1.48__twitter_refresh_token.sql @@ -0,0 +1 @@ +alter table twitter add column refresh_token character varying(255) not null default ''; \ No newline at end of file diff --git a/src/main/resources/schema-h2.sql b/src/main/resources/schema-h2.sql index 97b16799..7aaee493 100644 --- a/src/main/resources/schema-h2.sql +++ b/src/main/resources/schema-h2.sql @@ -212,6 +212,7 @@ CREATE MEMORY TABLE "PUBLIC"."FACEBOOK"( CREATE MEMORY TABLE "PUBLIC"."TWITTER"( "USER_ID" INTEGER NOT NULL, "ACCESS_TOKEN" CHARACTER VARYING(255), + "REFRESH_TOKEN" CHARACTER VARYING(255) NOT NULL DEFAULT '', "ACCESS_TOKEN_SECRET" CHARACTER(64) NOT NULL, "UNAME" CHARACTER(64) NOT NULL, "TS" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, diff --git a/src/main/resources/schema-mysql.sql b/src/main/resources/schema-mysql.sql index 0bee0ed6..0e035d5e 100644 --- a/src/main/resources/schema-mysql.sql +++ b/src/main/resources/schema-mysql.sql @@ -517,6 +517,7 @@ DROP TABLE IF EXISTS `twitter`; CREATE TABLE `twitter` ( `user_id` int(10) unsigned NOT NULL, `access_token` varchar(255) DEFAULT NULL, + `refresh_token` varchar(255) NOT NULL DEFAULT '', `access_token_secret` char(64) NOT NULL, `uname` char(64) NOT NULL, `ts` timestamp NOT NULL DEFAULT current_timestamp(), diff --git a/src/main/resources/schema-sqlite.sql b/src/main/resources/schema-sqlite.sql index 2b32635e..b0858f1b 100644 --- a/src/main/resources/schema-sqlite.sql +++ b/src/main/resources/schema-sqlite.sql @@ -211,6 +211,7 @@ CREATE TABLE telegram ( CREATE TABLE twitter ( user_id INTEGER NOT NULL, access_token character varying(64) NOT NULL, + refresh_token character varying(255) NOT NULL DEFAULT '', access_token_secret character varying(64) NOT NULL, uname character varying(64) NOT NULL, ts DEFAULT (strftime('%s','now') || substr(strftime('%f','now'),4)) NOT NULL, diff --git a/src/main/resources/schema-sqlserver.sql b/src/main/resources/schema-sqlserver.sql index 11e07b7b..947e5403 100644 --- a/src/main/resources/schema-sqlserver.sql +++ b/src/main/resources/schema-sqlserver.sql @@ -223,6 +223,7 @@ CREATE TABLE telegram ( CREATE TABLE twitter ( user_id bigint NOT NULL, access_token character varying(64) NOT NULL, + refresh_token character varying(255) NOT NULL DEFAULT '', access_token_secret character varying(64) NOT NULL, uname character varying(64) NOT NULL, ts datetimeoffset DEFAULT CURRENT_TIMESTAMP NOT NULL, diff --git a/src/main/resources/templates/views/post.html b/src/main/resources/templates/views/post.html index a77fa3bd..10f1ccb0 100644 --- a/src/main/resources/templates/views/post.html +++ b/src/main/resources/templates/views/post.html @@ -2,6 +2,9 @@ {% import "views/macros/tags" %} {% block content %}
+{%if twitter1 %} +

Reconnect or disable your Twitter account

+{% endif %}

Фото: -- cgit v1.2.3