3

I have three websites setup. Lets call them User Portal, Admin Portal, Login Portal.

Both Admin and User Portal will use Login Portal to authenticate, however the two will serve different content. My problem is this. If I am on User Portal and I change language from English to Spanish, then when I visit either Admin Portal or Login Portal it should show me everything in spanish. Then if I switch my language to french while on Login Portal, then both Admin and User Portals should show french.

Basically no matter what website I am on and I switch language, I would like the other two websites to know about that change and act accordingly. Now I am using .NET Core and I'm using the native way to do localization. Meaning I have my resource files setup and I use a cookie to store the current language.

I know I can't edit cross domain cookies so I'm a little bit lost as to how can I achieve this. The solutions I thought of was that when you change a language on one website, you do a form post to the other two to keep them updated and that just feels rather messy. It would also get a lot worse if I were to add a 4th portal as well.

Second solution I thought of is to keep the value in the database and then write middle-ware that intercepts every request and checks the database and sets the language. This also feels very wrong as I'm adding more traffic to my database on every request ever sent.

Are there better ways I can do this?

Bagzli
  • 5,388
  • 14
  • 58
  • 124
  • 1
    If all the applications are in the same domain but different subdomains, you can still issue the cookie for the domain. – Reza Aghaei Aug 08 '19 at 18:02
  • you can use a query parameter and show accordingly. – It's a trap Aug 09 '19 at 11:20
  • @It'satrap that only works if the sites are being redirected, it wouldn't work if the user chooses on his own to get there via a bookmark for example. – Bagzli Aug 09 '19 at 19:02
  • There are only three options so the choice is limited: cookie, database, or url. Without additional information, it's hard to say which one is better for your case. Are these subdomains under the same base domain? – Tengiz Aug 13 '19 at 19:50

6 Answers6

2

If all three portals are subdomains under the same base domain, then use cookies. The cookie values are accessible by all subdomains if it's created for the base domain. For example, read this: Share cookie between subdomain and domain

If the portals are on different domains altogether, then you need to use either a URL or a database approach. In my opinion, the approach differs based on what you can do with your use cases or technology.

My first preference is URL - make the language code part of the URL. e.g., many websites do this: www.domain.com/en-us/page.html. This URL has language and country codes in it. During the page load, these codes are honored and the page renders in the chosen locale. When you change language, redirect the user to the URL with the right locale in it. When going between the portals, keep the locale in the URL, assuming that all portals have a similar URL structure.

My less preferred approach is a database. This approach also comes with caveats that you need to watch out. Namely, avoid reading the database that holds the language selection from the foreign portal. i.e., if the language selection is managed by the user portal, the admin portal will need to ask user portal about this value instead of reading the database directly. I recommend thinking about this intricacy from the architecture standpoint (microservices, bounded contexts, you name it). So the database approach is more involved and thereby, less preferred due to such complications. I think I don't need to elaborate more than that for the database approach as you get the point.

Tengiz
  • 6,773
  • 22
  • 37
1

I would suggest the Second solution considering you have 3 web application deployed to different domain names. What you could do-

  1. You can use middle-ware with in-memory cache e.g. aerospike/cassandra/mongodb or you can use hosted one firebase
  2. Optimize the language value read call. On the browser, fire the language read call when the user gets to focus on the window or tab. Window focus() event.
  • The language read call comes from the cookie and is done by the framework, I'm not sure how I would move that to front end. It makes no sense to me, sorry. – Bagzli Aug 06 '19 at 23:04
1

Make a common url for your cookie, this can be done by calling a something from a shared CDN, I use a single private CDN for shared css, images and other resources. use a cookie from that URL and all websites can share this.

You see this technique a lot with Like buttons that bring a cookie with the image.

I have a cdn.domain.Com and www.domain.com, mobile.domain.com and api.domain.com the later 3 use the cdn so that it pulls from cash as much as possible and also allows me to update all sites from a common source. This isn't limited to sub domains or child domains, any domain will do

When you have a language identifier you can then load your static text using one of several localisation strategies Microsoft shows how to do this here

To maintain the languages in a database is perhaps a bit heavy, I like ResXManager it allows you to maintain several languages at the same time as well as export & import that you can use to have native speakers fix the "google translations". You can use Excel, easyer to share than a database…

Hope that this is easyer then the comments bellow. If you need code for a database then let me know and I will post it here.

Walter Vehoeven
  • 2,461
  • 14
  • 28
  • I am a little confused, I think that you are saying that the api would basically know the value of the current choice of language. How do I make this work with .net core framework? It would mean i have to write middle-ware that happens on every request and calls the api to check if language choice has changed? Sounds very similar to my solution #2 just instead of db you are saying call api. Am I understanding you correctly? – Bagzli Aug 10 '19 at 15:37
  • You are reading a cookie… for you to be able to share a cookie between domains then the best way to do this is to use a shared domain, you can add a service to manage language resources or anything you'd like to share. this is basic web stuff, doesn't matter the version you are using – Walter Vehoeven Aug 11 '19 at 15:22
  • Yes, I"m using a cookie and I'm fully aware how that part works. I'm trying to talk to you about the more finer details which are past the basic web stuff. Now in my case the domains may end up being different and I cannot use the shared cookie policy that you are suggesting in your comment. What it seems to me is that you are suggesting I write middleware that fires off on every request to ask api what is user's language. It would have to be on every request because anything else would mean not up to date potentially. As such, I defer to my first comment. – Bagzli Aug 11 '19 at 15:30
  • I'm sorry, I feel I"m losing you again. You are talking about web headers now and I don't see how this helps. Are you saying I should use redirects now to control this as oppose to middle-ware? What is the header for? How is it used? – Bagzli Aug 11 '19 at 15:36
  • You use this to maintain your language selection. To make you website language agnostic you'd need to implement globalisation. You can round-trip to the database and load all labels for a given page to, but why... have a look at https://marketplace.visualstudio.com/items?itemName=TomEnglert.ResXManager – Walter Vehoeven Aug 11 '19 at 15:48
  • updated the answer, if you need middleware code then let me know – Walter Vehoeven Aug 11 '19 at 16:10
0

I mentioned the code for a "database" solution

First, this approach uses a TagHelper that I have created for this.

how it works, Step 1: The site renders the view with the tag helper. Step 2: The tag helper gets noticed by the middleware and get's executed. The tag helper then goes to the injected repository and get's the right inner html. for me the html would be.

<h5 language-key="CTrader-C1-H5">The C Trading with the C1 Algorithm</h5>
<p language-key="CTrader-C1">
              The C1 Trader trades against the trend and allows you to..
</p>

Step 3: based on the users role I load JavaScript file that allows online editing of the pages text

Step 4: Update the template _ViewImports.cshtml to load the TagHelpers, in my case that would be:

@using CATS.Web.Shared.Repositories
@using CATS.Web.Shared.Infrastructure.TagHelpers
@using Microsoft.AspNetCore.Localization
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.AspNetCore.Identity

@addTagHelper "*, CATS.Web.Shared"
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

The Tag helper is defined like this:

[HtmlTargetElement("p",Attributes = CatsLanguageKey)]
[HtmlTargetElement("span", Attributes = CatsLanguageKey)]
[HtmlTargetElement("a", Attributes = CatsLanguageKey)]
[HtmlTargetElement("li", Attributes = CatsLanguageKey)]
[HtmlTargetElement("h1", Attributes = CatsLanguageKey)]
[HtmlTargetElement("h2", Attributes = CatsLanguageKey)]
[HtmlTargetElement("h3", Attributes = CatsLanguageKey)]
[HtmlTargetElement("h4", Attributes = CatsLanguageKey)]
[HtmlTargetElement("div", Attributes = CatsLanguageKey)]
public class LanguageTagHelper: TagHelper
{
    private const string CatsLanguageKey= "language-key";

    private readonly ILanguageRepository _repository;
    private readonly ClaimsPrincipal _user;
    private readonly IMemoryCache _memoryCache;

    public LanguageTagHelper(ILanguageRepository repository, IHttpContextAccessor context, IMemoryCache memoryCache)
    {
        _repository = repository;
        _user = context.HttpContext.User;
        _memoryCache = memoryCache;
    }

    [HtmlAttributeName(CatsLanguageKey)]
    public string Key { get; set; }



    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {

        var childContent = await output.GetChildContentAsync();
        if (!childContent.IsEmptyOrWhiteSpace)
        {
            var textItem = _repository.GetHtml(Key, childContent.GetContent().Trim());
            if (_user.Identity.IsAuthenticated && _user.IsInRole(MagicStrings.ROLE_TEXTER))
            {
                output.Attributes.Add("data-language-target", textItem.Language);
                output.Attributes.Add("data-language-key", textItem.Key);
                var html = new HtmlString(textItem.Text);
                output.Content.SetHtmlContent(html);

                _memoryCache.Remove(Key);
            }
            else
            {
                string text = string.Empty;
                if (!_memoryCache.TryGetValue(Key, out text))
                {
                    text = Regex.Replace(textItem.Text, @">\s+<", "><", RegexOptions.Compiled | RegexOptions.Multiline);
                    text = Regex.Replace(text, @"<!--(?!\s*(?:\[if [^\]]+]|<!|>))(?:(?!-->)(.|\n))*-->", "", RegexOptions.Compiled | RegexOptions.Multiline);
                    text = Regex.Replace(text, @"^\s+", "", RegexOptions.Compiled | RegexOptions.Multiline);
                    text = Regex.Replace(text, @"\r\n?|\n", "", RegexOptions.Compiled | RegexOptions.Multiline);
                    text = Regex.Replace(text, @"\s+", " ", RegexOptions.Compiled | RegexOptions.Multiline);
                    _memoryCache.Set(Key, text, new MemoryCacheEntryOptions() { Priority= CacheItemPriority.Low, SlidingExpiration= new TimeSpan(hours:1,minutes:0,seconds:0) });
                }
                var html = new HtmlString(text);
                output.Content.SetHtmlContent(html);

            }


        }
    }
}

In the above code I also make sure I remove the cashed text incase the user is a texter else he would never see his updated text..

The java script that would open create and open the online text editor looks like this.

var languageUrl="";
var languageResetUrl = "";


function ResetPageText(key){
//    var element = $("[data-language-key='" + key + "']");
//    var key = element.data("language-key");
    var args = {
        __RequestVerificationToken: gettoken(),
        textKey: key
    };
    $.post(languageResetUrl, args, function (data, textStatus, jqXHR) {
        location.reload();
    });


}
function SavePageText(key)
{
    var element = $("[data-language-key='" + key + "']");

    var lkey = element.data("language-key");
    var language = element.data("language-target");
    var text = $("#editor_" + lkey).val();


    var model = {
        __RequestVerificationToken: gt(),
        textKey: key,
        textLanguage: language,
        textValue: $.trim(text),
        salt: st()
    };

    if(text.length===0){
        alert('Text did not contain any characters, translation not saved');
        return;
    }

    $.ajax({
        url: languageUrl,
        method: 'POST',
        data: model,
        contentType: 'application/x-www-form-urlencoded',
        headers: { 'X-XSRF-TOKEN': model.__RequestVerificationToken, 'X-Cats-Salt': model.salt },
        success: function (data, textStatus, jqXHR) {
            var sender = $("[data-language-key='" + data.key + "']");
            sender.html(data.text);

            $("#dlg").fadeOut('slow', function () {
                $("#dlg").html("");
            });
        }
    });



}

function UpdatePreview(key)
{
    var preview = $("#2a" + key + "_pv");
    preview.html($("#editor_" + key).val());
}


function ShowEditor(element)
{
    var _key = $(element).data("language-key");
    var _language = $(element).data("language-target");
    var _value = $(element).html();
    var _data = { Key: _key, Language: _language, Value: _value };
    var form = [
        "<div class='modal-container' id='d" + _key + ">"
        , "  <div class='modal-dialog'>"
        , "     <div class='modal-content'>"
        , "         <div class='modal-header'>"
        , "             <button type='button' class='close' onclick='CancelEdit(\"" + _key + "\")'><span class='white'>&times;</span></button>"
        , "             <h4 class='modal-title'>Update text element \"" + _key+"\" in language \"" + _language + "\"</h4>"
        , "          </div >"
        , "          <div class='modal-body'>"
        , "          <div id='exTab1' >"
        , "             <ul class='nav nav-pills'>"
        , "                 <li class='active'><a href='#1a" + _key +"' data-toggle='tab'>HTML</a></li>"
        , "                 <li><a href='#2a" + _key +"' data-toggle='tab' onclick='UpdatePreview(\""+_key+"\")'>Preview</a></li>"
        ,"              <ul>"
        , "             <div class='tab-content clearfix'>"
        , "                 <div class='tab-pane active' id='1a" + _key+"'>"
        , "                     <textarea class='editBox pad5' name='textValue' id='editor_" + _key + "' spellcheck='true' lang='" + _language + "'>" + _value + "</textarea >"       
        , "                 </div>"
        , "                 <div class='tab-pane' id='2a" + _key + "'>"
        , "                     <div class='preview' id='2a" + _key + "_pv'>"
        , _value
        , "                     </div>"
        , "                 </div>"
        , "             </div>"
        , "         </div>"    
        , "         <div class='modal-footer'>"
        , "             <button type='button'  class='btn btn-default glyphicon glyphicon-repeat pad5' title='reset text to original' onclick='ResetPageText(\"" + _key + "\")'>Reset</button> "
        , "             <button type='button'  class='btn btn-default glyphicon glyphicon-ok pad5' title='Save changes' onclick='SavePageText(\"" + _key + "\")'>Save</button>"

        , "         </div>"  
        , "     </div>"    
        , "  </div>"
        ,"</div>"
    ].join("\n");


    $("#dlg").html(form);
    $("#dlg").fadeIn();
}


function CancelEdit(id)
{
    $("#dlg").fadeOut(400, 'swing', function () {
        $("#dlg").html("");
    });

}

Element.prototype.remove = function () {
    this.parentElement.removeChild(this);
};

NodeList.prototype.remove = HTMLCollection.prototype.remove = function () {
    for (var i = this.length - 1; i >= 0; i--) {
        if (this[i] && this[i].parentElement) {
            this[i].parentElement.removeChild(this[i]);
        }
    }
};


$(document).ready(function () {

    $('[data-language-key]')
        .on("click",
        function (){
            $(this).on("dblclick", ShowEditor(this));
    });

});

I secure the JavaScript by adding "Salt" to my header ensuring that only the right user with the right salt and the right IP can update the site's text to avoid "3rd party" updates ;-)

I only load the script in the my _layour.cshtml shared page like this, mahig strings is just a class with magic strings like the once I use for role names when the user is in a specific role, hence he must be logged in before it gets activated.

<Roles app-role="@MagicStrings.ROLE_TEXTER">

    <script src="~/js/Translate.js" type="text/javascript"></script>
    <script>
        languageUrl      = '@Url.Action(action: "Update", controller: "PageText")';
        languageResetUrl = '@Url.Action(action: "Reset", controller: "PageText")';
        currentUrl       = '@Context.Request.Path';
    </script>

</Roles>

My Language is managed by the Language Repository. The code for this would be:

public class LanguageRepository : BaseRepository, ILanguageRepository
{

    private readonly IMemoryCache _memoryCache;
    private readonly IHttpContextAccessor _context;


    public LanguageRepository(AppDbContext _db, IHttpContextAccessor context
        , IMemoryCache memoryCache) 
        : base(_db)
    {
        _memoryCache = memoryCache;
        _context = context;
    }


    private string Url
    {
        get {
            return _context.HttpContext.Request.Path;
        }
    }


    /// <summary>
    /// Gets the language that the user is using.
    /// </summary>
    /// <value>
    /// The language.
    /// </value>
    private string Language
    {
        get {

            var code = System.Threading.Thread.CurrentThread.CurrentCulture.TwoLetterISOLanguageName.ToLower();
            if (new[] { "en", "de", "fr", "nl" }.Contains(code))
                return code;
            else
                return "en";
        }
    }
    public TextItem GetHtml(string key, string defaultIfNull)
    {
        var cashKey = string.Concat("tc_",Language ,"_", key);
        if (!_memoryCache.TryGetValue<TextItem>(cashKey, out TextItem result))
        {
            result = new TextItem(key: key, language: Language);
            using (var cmd = db.Database.GetDbConnection().CreateCommand())
            {
                if (cmd.Connection.State != System.Data.ConnectionState.Open)
                    cmd.Connection.Open();
                cmd.CommandText = "dbo.GetPageText";
                cmd.CommandType = System.Data.CommandType.StoredProcedure;
                cmd.Parameters.Add(new SqlParameter("@Key", System.Data.SqlDbType.VarChar) { Value = key, Size = 150 });
                cmd.Parameters.Add(new SqlParameter("@Language", System.Data.SqlDbType.VarChar) { Value = Language, Size = 3 });
                cmd.Parameters.Add(new SqlParameter("@DefaultIfNull", System.Data.SqlDbType.NVarChar) { Value = defaultIfNull, Size = 4000 });
                cmd.Parameters.Add(new SqlParameter("@Url", System.Data.SqlDbType.VarChar) { Value = Url, Size = 150 });
                result.Text = cmd.ExecuteScalar().ToString();
            }

            _memoryCache.Set<TextItem>(cashKey, result, MemoryOptions);
        }
        return result;
    }



    public bool SetHtml(string key, string value)
    {
        var cashKey = string.Concat("tc_", Language, "_", key);
        TextItem result= new TextItem(key, Language) { Text= value };
        _memoryCache.Set<TextItem>(cashKey, result, MemoryOptions);

        using (var cmd = db.Database.GetDbConnection().CreateCommand())
        {
            if (cmd.Connection.State != System.Data.ConnectionState.Open)
                cmd.Connection.Open();

            cmd.CommandText = "dbo.SetPageText";
            cmd.CommandType = System.Data.CommandType.StoredProcedure;
            cmd.Parameters.Add(new SqlParameter("@Key", System.Data.SqlDbType.VarChar) { Value = key, Size = 150 });
            cmd.Parameters.Add(new SqlParameter("@Language", System.Data.SqlDbType.VarChar) { Value = Language, Size = 3 });
            cmd.Parameters.Add(new SqlParameter("@Value", System.Data.SqlDbType.NVarChar) { Value = value, Size = 4000 });
            return cmd.ExecuteNonQuery()!=0;
        }           

    }
    public async Task<bool> SetHtmlAsync(string key, string value)
    {
        var cashKey = string.Concat("tc_", Language, "_", key);
        TextItem result= new TextItem(key, Language) { Text= value };
        _memoryCache.Remove(cashKey);
        _memoryCache.Set<TextItem>(cashKey, result, MemoryOptions);

        using (var cmd = db.Database.GetDbConnection().CreateCommand())
        {
            if (cmd.Connection.State != System.Data.ConnectionState.Open)
                cmd.Connection.Open();

            cmd.CommandText = "dbo.SetPageText";
            cmd.CommandType = System.Data.CommandType.StoredProcedure;
            cmd.Parameters.Add(new SqlParameter("@Key", System.Data.SqlDbType.VarChar) { Value = key, Size = 150 });
            cmd.Parameters.Add(new SqlParameter("@Language", System.Data.SqlDbType.VarChar) { Value = Language, Size = 3 });
            cmd.Parameters.Add(new SqlParameter("@Value", System.Data.SqlDbType.NVarChar) { Value = value, Size = 4000 });
            return await cmd.ExecuteNonQueryAsync()!=0;
        }           

    }

    public async Task<bool> DeleteHtmlAsync(string key)
    {
        var cashKey = string.Concat("tc_", Language, "_", key);
        _memoryCache.Remove(cashKey);

        using (var cmd = db.Database.GetDbConnection().CreateCommand())
        {
            if (cmd.Connection.State != System.Data.ConnectionState.Open)
                cmd.Connection.Open();

            cmd.CommandText = "dbo.DeletePageText";
            cmd.CommandType = System.Data.CommandType.StoredProcedure;
            cmd.Parameters.Add(new SqlParameter("@Key", System.Data.SqlDbType.VarChar) { Value = key, Size = 150 });
            return await cmd.ExecuteNonQueryAsync() != 0;
        }

    }

    private MemoryCacheEntryOptions MemoryOptions=> new MemoryCacheEntryOptions() { Priority = CacheItemPriority.High, SlidingExpiration = DateTime.Now.AddHours(6) - DateTime.Now };

}
Walter Vehoeven
  • 2,461
  • 14
  • 28
  • This is very in debt.Thank you. The way that i setup language in my website is I just I simply create a cookie and I let .net core framework take it away. I dont think I need to do all of this. Its not a problem for me to create a cookie or to pull language value from the database or even to write middle-ware that will trigger on each request. My goal of this question was to find a technique that will allow me to keep the value of that cookie up to date if it is changed on another website. Based on everything I have read here it seems I just need to have middle-ware that fires on each request. – Bagzli Aug 11 '19 at 19:07
  • That would be https://docs.microsoft.com/en-us/aspnet/core/fundamentals/localization?view=aspnetcore-3.0 – Walter Vehoeven Aug 11 '19 at 19:43
0

Keep the user's language stored in the database.

That way, there's only one location that you ever need to check for language, and no need for cookies. No matter which site you're on, the back-end simply needs to look at the database, check the language and do what's required (reload the page, run some javascript to switch sentences to a new language, or whatever is most convenient).

If you want the language transition to happen in real-time across all websites, i.e. if you change language on the admin portal in one window, and have the login portal open in another window and want the language to update on both sites, you'll need to use setInterval() on each site to create a poll that calls the API to check the user's language (stored on the database) and act accordingly.

Stuart Aitken
  • 691
  • 10
  • 23
0

Your answer should be in your current design.

I assume among your portals there is a portal which makes decision what is/was user selected language?

Consider this case, if user has accessed a deep link in your User-Portal, you may be transferring him to Login-Portal and then comeback to User-Portal. During this transfer how are you maintaining your language selection?


  1. Most likely by a cookie; since you don't want him to login -- this mean you have global cookies and your sub-domain should be able to write/update language cookie.

  2. If you are using oAuth, then language selection could shared as querystring or request-header or a claim inside token (weird - but I've seen systems like these)

In both above cases you may want to let your decision-making portal know about changed user-preference for future use and keep sending selected language as global-cookie or querystring or request-header.

Incase using token, just request a new token -there is some coding required.

akhileshcoer
  • 162
  • 10