Tang-yuan

元宵节将近,利用空余时间做汤圆。以前做出来的面团总是很粘手,还是参考了小高姐的材料比例,才做出不粘手的面团。不粘手,才容易包。

总结如下:

材料

  • 糯米粉 250克
  • 热开水 140克
  • 室温水 70克
  • 馅料 购买现成的红豆沙

做法

  1. 糯米中导入热开水,一边倒一边搅拌。再慢慢加入室温水,调整其用量。揉合成有光面的团,盖好放置半小时。
  2. 搓成长条,平均切成3、4段,放到有盖容器以免风干。拿一段出来搓成直径与汤圆一致的长条,再切成小粒来包馅。
  3. 按小高姐的做法,用拇指在小面团中间压下去,弄成小碗状,放入馅料,再包上即可。

个人比较喜欢用清水煮汤圆,更能突出馅料的甜味。水烧开后放入汤圆,等漂起来后就可以出锅开吃了。

一次包的太多,可以放到冰箱冷冻,想吃就可以直接拿来煮。

Make Hamburger with Potato Bread

最近迷上小高姐的视频,容易上手且很实在的料理教程。昨天做了土豆面包,比较成功,于是今天就做了汉堡。说是汉堡,其实更像是三明治(Sandwich),用调过味的肥牛代替汉堡扒,加上生菜,也挺好吃。其实肉汁足够,就会有不错的口感了。

视频教程如下

土豆面包8个,总结如下:

材料

  • 黄皮土豆 150克(去皮后的重量)
  • 牛奶 220克(注:懒得买牛奶,冲了杯奶粉代替)
  • 酵母 2克
  • 鸡蛋 1个
  • 糖 25克
  • 油 25克(个人喜欢20克)
  • 高筋面粉 320克
  • 有盐黄油 小许(刷面包表面)

做法

  1. 土豆削皮、切小块,微波炉热4~5分钟,弄成土豆泥。没微波炉可以蒸熟。
  2. 牛奶在微波炉热30秒,加一点到土豆泥,搅拌成糊,再倒入剩下的混合。混合物温度不高于体温,依次加入酵母、鸡蛋、糖、油,搅拌混合。最后加入高筋面粉,弄成面粉团。
  3. 饧面(发酵)2小时后,撒点干面粉,排气。取出切成8等份,各自进一步排气后再揉成球,放到烤盘的纸上。表面扫第一层黄油,盖上保鲜膜发酵45到60分钟,再扫第二层黄油。
  4. 烤箱预热,190摄氏度烤17到20分钟。

汉堡的做法

  1. 做好的土豆面包放凉,中间切开,里面涂上黄油。再把涂有黄油的一面放到锅里煎一下,煎香即可。
  2. 至于馅料,可以参考小高姐的视频,做个汉堡排加相关配料。我们直接用煎香的肥牛、自家种的生菜、煎蛋等,夹到面包里即可。

Pizza

后记:本文涉及的Pizza是硬底,不推荐。但可以看看文中小高姐的软底做法。

昨天找出冰箱里的一堆材料,于是计划今天做Pizza。以前做过很多次,一直以Pizzahut的为目标,也没有实现满意的饼底。看过小高姐视频,才知道面团不是最重要,烘烤方法才是重点。虽然视频中的烘焙石板实力劝退,但是小高姐的视频很有启发性:

说回正题,由于囤了高筋面粉,而且没有烘焙石板(Pizza石头),参考了这个12吋Pizza的面团做法:

这个12吋Pizza总结如下:

材料

  • 高筋面粉 165克
  • 温水 95克
  • 酵母 2克
  • 糖 一小勺
  • 盐 2克

做法

  1. 酵母加入温水、糖(糖是用于激活酵母)、盐搅拌至融化。再倒入面粉中,继续加温水搅拌成絮状,揉成团至表面光滑。盖上保鲜膜,醒发一个半小时左右,直到面团成两倍大。发酵过程注意室温不能太低,否则不能发大。然后拉伸到盘子大小再放到盘子上,放上其它材料。尽量拉薄,更容易烤熟。
  2. 预热到200摄氏度度的烤箱(上下烤),烤20分钟左右。注意不能烤太久,否则会变太硬,甚至烤焦。

做了个烤盘大小的Pizza,效果还不错。还放了Pizza香料,增加风味。

Write a Small Service for Learning Golang

某天接触到某个用Golang实现的程序,不仅体积小,还支持几乎所有种类的CPU,于是下决心学习一下Go这门语言。虽然刚出来的时候就想学,但那时据说有很多坑(比如语法可能会变),就放弃了。现在连被成为垃圾的包管理也有了升级,觉得是时候去学。恰好要把一个二级域名绑定IP的小服务迁移到VPS上,于是干脆用Go重新实现(原来是用Python3)这个服务。

首先要阅读相关教程。初学者教程当然是官方入门教程:

官方入门教程太简单(毕竟Go本身语法就是简单),还需要阅读其它相关知识:

这个小服务,就是个web服务。客户端发起带有key和token的请求,此服务会验证有效的授权,然后把对应的二级域名与客户端IP绑定。配置信息以json格式保存在文本文件。客户端IP会记录在对应log文件,以方便每次比较客户端IP是否变化了。每次更新二级域名与IP的绑定,则会记录log。相关文件及代码如下:

配置文件config.json

{
"ServIpPort": ":12345"
,"DnsKey": "dnspod的key"
,"DnsToken": "dnspod的token"
,"DomainId": "dnspod的域名id"
,"SubDomainId": {"abc":"dnspod的二级域名id", "efg":"dnspod的二级域名id"}
,"Users": [
    {
        "Key":"client1"
        ,"Token":"aaa123456"
        ,"SubDomains": ["abc"]
    }
    ,{
        "Key":"client2"
        ,"Token":"xxx789012"
        ,"SubDomains": ["efg"]
    }
]
}

小服务的代码ddnsServ.go

package main

import (
    "fmt"
    "log"
    "net"
    "net/http"
    "net/url"
    "strings"
    "os"
    "path/filepath"
    "io/ioutil"
    "time"
    "encoding/json"
)

type User struct {
    Key string
    Token string
    SubDomains []string
}

type Config struct {
    ServIpPort string
    DnsKey string
    DnsToken string
    DomainId string
    SubDomainId map[string]string /*key:sub domain name, value:sub domain id*/
    Users []User
}

var (
    curPath string
    ipLogPath string
    historyPath string
    config Config
)

func init() {
    curPath, _ = filepath.Abs(filepath.Dir(os.Args[0])) 
    ipLogPath = curPath + "/ip"
    historyPath = curPath + "/log"
    for _, path := range []string{ipLogPath, historyPath} {
        if err := initPath(path); err != nil {
            fmt.Printf("Init failed. Error info:%s\n", err)
            os.Exit(-1)
            return
        }
    }

    if err := initConfig(curPath + "/config.json"); err != nil {
        fmt.Printf("Init failed. Error info:%s\n", err)
        os.Exit(-1)
        return
    }
}

func initPath(path string) error {
    s, err := os.Stat(path)
    if err == nil && !s.IsDir() {
        return fmt.Errorf("The path is existed, but it is not a directory! Path is:%s", path)
    }
    if err != nil && os.IsNotExist(err) {
        e := os.Mkdir(path, os.ModePerm)
        return e 
    }
    return nil
}

func initConfig(configPath string) error {
    configData, err := ioutil.ReadFile(configPath)
    if err != nil {
        return fmt.Errorf("Failed to read config file: %s! Error info: \n%s", configPath, err)
    }

    err = json.Unmarshal(configData, &config)
    if err != nil {
        return fmt.Errorf("Failed to load config data! Error info: \n%s", err)
    }
    return nil
}

// get client IP address 
func GetClientIP(r *http.Request) string {
    xForwardedFor := r.Header.Get("X-Forwarded-For")
    ip := strings.TrimSpace(strings.Split(xForwardedFor, ",")[0])
    if ip != "" {
        return ip
    }

    ip = strings.TrimSpace(r.Header.Get("X-Real-Ip"))
    if ip != "" {
        return ip
    }

    if ip, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)); err == nil {
        return ip
    }

    return ""
}

func GetLogFilePath(logPath string, subDomain string) string {
    return fmt.Sprintf("%s/%s.log", logPath, subDomain)
}

// get the file path which saved ip address of subDomain
func GetIpLog(subDomain string) string {
    path := GetLogFilePath(ipLogPath, subDomain)
    buf, err := ioutil.ReadFile(path)
    if err != nil {
        return ""
    }
    return string(buf)
}

func SaveIpLog(subDomain string, ip string) {
    path := GetLogFilePath(ipLogPath, subDomain)
    ioutil.WriteFile(path, []byte(ip), 0644)
    /*
    err := ioutil.WriteFile(path, []byte(ip), 0644)
    if err != nil {
        panic(err.Error())
    }
    */
}

func SaveHistoryLog(subDomain string, ip string) error {
    path := GetLogFilePath(historyPath, subDomain)
    logFile, err := os.OpenFile(path, os.O_CREATE | os.O_WRONLY | os.O_APPEND, 0644)
    if err != nil {
        return fmt.Errorf("Failed to open history log file: %s! Error info: \n%s", path, err)
    }
    defer logFile.Close()

    nowStr := time.Now().Format("2006-01-02 15:04:05")
    log := fmt.Sprintf("%s, ip:%s\n", nowStr, ip)
    logByte := []byte(log)
    n, err := logFile.Write(logByte)
    if err == nil && n < len(logByte) {
        return fmt.Errorf("Failed to save history log file: %s! Error info: \nwrite file failed", path)
    }
    return nil
}

func UpdateDns(subDomain string, ip string) error {
    values := url.Values{}
    values.Add("login_token", config.DnsKey + "," + config.DnsToken)
    values.Add("format", "json")
    values.Add("lang", "en")
    values.Add("error_on_empty", "no")

    values.Add("domain_id", config.DomainId)
    values.Add("record_id", config.SubDomainId[subDomain])
    values.Add("sub_domain", subDomain)
    values.Add("record_type", "A")
    values.Add("record_line", "默认")
    values.Add("value", ip)

    client := &http.Client{}
    req, err := http.NewRequest("POST", "https://dnsapi.cn/Record.Modify", strings.NewReader(values.Encode()))
    if err != nil {
        // handle error
    }

    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    req.Header.Set("Accept", "text/json")
    resp, err := client.Do(req)

    defer resp.Body.Close()

    _, err2 := ioutil.ReadAll(resp.Body)
//    fmt.Println(string(s))
    if err2 != nil {
        return err2
    }
    return nil
}

func handler(w http.ResponseWriter, r *http.Request) {
    // Verify authorization
    var subDomains []string
    key := r.PostFormValue("key") // get key of POST
    token := r.PostFormValue("token") // get token of POST
    for _, user := range config.Users {
        if user.Key == key && user.Token == token {
            subDomains = user.SubDomains
            break
        }
    }
    if subDomains == nil || len(subDomains) <= 0 {
        w.WriteHeader(http.StatusNotFound)
        return
    }

    // get IP
    ip := GetClientIP(r)
    fmt.Fprintf(w, "%s", ip)

    for _, subDomain := range subDomains {
        // get last IP of subDomain
        ipLog := GetIpLog(subDomain) 
        if ip == ipLog {
            // IP is not changed
            continue
        }
    
        // update DNS, bind IP to subDomain
        err := UpdateDns(subDomain, ip)
        if err == nil {
            // update success, save new IP
            SaveIpLog(subDomain, ip)
            SaveHistoryLog(subDomain, ip)
        }
    }
}

func main() {
    http.HandleFunc("/", handler)
    log.Fatal(http.ListenAndServe(config.ServIpPort, nil))
}

关于部署,就用nginx弄个反向代理,指向这个小服务的端口。由于VPS上装了Debian9,可以配置systemd来设置系统服务。

客户端只需发起post请求,把key和token发过来就可以了。以下用curl实现:

#!/bin/sh

#curl命令的参数解析:
# -X 请求的方法。这里用了POST,在HTTPS传输中,数据被加密
# --connect-timeout 连接超时时间,单位:秒
# -m/--max-time 数据传输的最大允许时间,单位:秒
# https://rpi.f...... 请求的URL
# -H/--header 请求头。要设置多个请求头,则设置多个-H参数
# -d/--data 请求数据。
curl -k -X POST --connect-timeout 5 -m 10 https://youdomain.xxx:12345/api/update_dns -H 'cache-control: no-cache' -H 'content-type: application/x-www-form-urlencoded' -d 'key=client1&token=aaa123456'

目前这个小服务工作良好。除了体积有点大(约7MB),其它都挺满意的。

Using PHICOMM N1 as Android TV Box

斐讯真是个神奇的公司,凭着其高质量的产品,本来可以好好做个科技公司。其崩盘后,这些优秀的产品给我们带来了一波又一波的惊喜。在某网站的推广下,加上大学同学推坑,终于入手了N1。刷上被清理过的Android系统后,非常适合作为智能机顶盒!主要还是价格不算贵。整理一下相关的资料:

一、相关介绍

N1相关的一切,这个文章都说得很清楚了,我也是跟着上面步骤的来折腾。只是相关的资源都不能很好地下载。

这个值得看看是由于N1的功耗很低,就算作为服务器不关机也不用担心电费的问题。

二、刷机经历

所需硬件:

1)双公头的USB 2.0线,用来连电脑刷系统。找两根USB线,把两个公头按同颜色的线连接即可。 2)网线,连接路由,用于远程adb操作N1。 3)USB鼠标或键盘,接到N1操作。 4)一台x86CPU的电脑,并且装了Windows的电脑,用来运行刷机工具。

所需软件:

1)webpad的rom,里面的“工具”包含了adb、降级工具、线刷工具等。喜欢这个rom的话,可以刷上,不用下载其它了。

工具说明:

a)“android-adb-fastboot_1.0.39.7z”,adb和fastboot工具 b)“斐讯T1、N1官方系统降级工具.zip”,官方ROM降级工具 c)“使N1进入线刷模式.zip”,降级后进入线刷模式,其实就是进入bootloader d)“USB_Burning_Tool_v2.1.6.zip”,Amlogic的线刷工具,只支持windows

2)RUSH固件

我跟着前面的刷机教程,选择了这个固件。但由于是精简的,几乎应用什么都没有,后面需要折腾。

3)救砖

由于刷ROM中途以为失败了而强制结束,导致刷成砖。最后找到网上的教程,算是比较简单的解决方法,如下:

简单来说是,拆机,短接两个触点,刷入T1固件(RUSH的rom包内有T1 ROM和救砖教程),再刷入所需的ROM

三、使用经验

1)遥控

本来可以买个蓝牙遥控,或者2.4无线遥控。但是本着省钱的原则,接了个有线鼠标上去,应付特殊情况。另外配对了一个蓝牙手柄,基本可以胜任遥控的工作。家里还有个Rapoo 1800 2.4G无线鼠键,可以考虑用上。

2)相关应用

  • 媒体中心:VLC,开源播放器,可以播放各种格式的视频,支持DLNA,支持多语音频等。个人感觉比Kodi好。
  • 横屏工具:还没找到好用无需破解的。由于很多apk都是手机端,默认不支持横屏,需要转个强制横屏工具。
  • 浏览器:无脑推荐Chrome,没考虑操作是否舒适之类,反正能在线追番就可以了。
  • 电视直播:这是个灰色地带。详细关注微信公众号”KUMI分享“,会有不定期推介。
  • 文件管理:ES Explorer,RUSH的ROM自带。支持FTP服务端和客户端,方便局域网内互传文件。
  • 桌面:直接用RUSH自带的那个,很简单,也有一点不方便,没去找其它的,凑合用吧。

3)游戏

N1自带空间不大,不适合玩大型游戏,而且大部分Android游戏都不支持手柄。

a)Minecraft破解版。家里部署了bedrock服务,手机装了正版Minecraft,但是N1没刷GAPPS,所以只能找可以登录的破解版了。跟孩子一起玩,大屏幕,还不错,但是画面略有卡顿。 b)赛车游戏。只是装了个SuperTuxKart来试试,效果还行,手柄还是不如手机的体感操控。 c)游戏机模拟器及游戏。能支持手柄,占用空间小,资源丰富,就只有模拟器了。曾经很期待地装上Dolphin模拟器,以为能玩Wii游戏,但是跑不起来。看来只能考虑旧主机的模拟器。

Certbot Renew with DNSPod

宽带的80、443端口不能使用了,更新免费的SSL证书(Let’s Encrypt的免费证书)就成问题了。后来找到相关的文章,说是可以通过DNS验证并更新,指向以下官方网址:

找DNSPod的插件时,发现github上居然有不同的版本(名称却是一样的),因此走了弯路(浪费了一个下午)。最后按照这个的说明,成功更新了证书。

简单来说,就是

1)去DNSPod.cn申请api授权 2)安装插件

pip install certbot-dns-dnspod

3)生成插件配置文件,例如保存到文件/etc/cetbot-dns-dnspod-credentials.ini。重点是双引号不可缺,token的格式是id和token以逗号分隔

certbot_dns_dnspod:dns_dnspod_email = "DNSPod账户的Email"
certbot_dns_dnspod:dns_dnspod_api_token = "api_id,api_token"

4)配置文件设置权限(只是为了安全,此步可不做)

sudo chmod 600 /etc/cetbot-dns-dnspod-credentials.ini

5)更新证书。xxx.com需要替换为相关域名。

certbot certonly -a certbot-dns-dnspod:dns-dnspod \
    --certbot-dns-dnspod:dns-dnspod-credentials /etc/cetbot-dns-dnspod-credentials.ini \
    -d xxx.com

证书更新成功后,会发现certbot的配置文件(/etc/letsencrypt/renewal/xxx.com.conf)也更新了。

QtScrcpy, the Android Screen Share and Control Software

本来一直在用Scrcpy,一个把手机屏幕显示在电脑屏幕的软件。作为一般的操作,可以接受。但是滑动太快,或者玩游戏,会出现马赛克。直到前几天发现了QtScrcpy,据称可以“吃鸡”!今晚终于在Lubuntu上编译出来,玩了下Minecraft,也看了下视频,非常不错~虽然已经在公司用了多天来划水。

详细的介绍,请查看项目的官方介绍。项目地址:https://github.com/barry-ran/QtScrcpy

应用场景:把手机屏幕的内容显示在电脑屏幕,支持 Windows、MAC、Linux 三大系统(其中 Linux 需要自己编译)。可以实现公司电脑上无痕划水(自备无限流量套餐、迷你蓝牙耳机,效果更佳),或者找个烂电脑作为手机伴侣(实现小屏转大屏)。

优点: 1)速度快! 720p可以玩游戏,1080p可以看电影。

2)有熄屏功能。手机画面投影在电脑上,手机屏幕同时可以关掉。

3)有功能完整的操作界面,基本的功能按钮都有,免得打命令。手机的输入可以直接用鼠标和键盘操作(跟 Scrcpy 一样)。

缺点: 1)输入法不能通过键盘选字、不能直接输入符号(这个缺点直接继承 Scrcpy )。直接用蓝牙键盘连手机的话,输入体验会好很多。

2)Linux上需要自己编译。装QtCreator、Android SDK、Android NDK……由于网络的问题,下载相关软件时折腾了一下。编译挺简单的,对于第一次接触QtCreator的我来说,一次编辑成功。对于没有编程经验的人来说,会有难度。但是这种人一般不会使用Linux。另外,QtCreator最好从 https://download.qt.io/archive/qt/ 下载安装包进行安装。

3)窗口模式不能随意调整大小(Scrcpy是可以的),只能固定大小,或者全屏。

4)手机声音不能通过电脑播放。不知道是不是我没找到设置的地方,所以需要配个蓝牙耳机。

在家使用还是有点浪费资源,毕竟要开两个机器来做一件事件。除了玩Minecraft PE,暂时想不到有什么应用场景。但是在公司划水的话,非常实用。

Thunderbird Setting Mail Template

以前一直用的Email客户端是Foxmail,但是某客服邮箱的邮件太多(几千封),一接收,Foxmail就挂了。后来换上Thunderbird就没事了,一直用了几年也没什么问题。但是公司内的同事基本都是用Foxmail,一回复邮件就显示我很异类(Thunderbird回复邮件的默认格式确实也太单调了)。于是找了找解决方案,设置回复邮件的模板。最后虽然不能完全模仿Foxmail的格式,但看着还行,也就这样吧。

首先设置帐户的签名如下,其中"xxx@abc.com"要替换为对应的Email地址:

<hr style="width:210px;height:1px;" color="#b5c4d" size="1" align="left" />
<div style="margin:10px;font-size:10pt">
<div>xxx@abc.com</div>
</div>

然后安装两个“扩展”:ReFwdFormatterSmartTemplate4,并重启Thunder。ReFwdFormatter是用于删除回复邮件时引用原文出现的蓝色竖线。SmartTemplate4就是设置回复邮件的模板。设置步骤如下:

1)打开ReFwdFormatter,取消全部勾选,并只勾选“Remove the ‘|’ prefix from quote in html mail.”,点“Save”按钮保存退出。

2)打开SmartTemplate4,选择对应的帐户。

2.1)点“回复”标签,勾选“将以下模板应用于回复消息”、“替换标准引用头文件”、“使用HTML(例如,<b>bold</b>)”,并在“模板”填入以下内容:

%sig%
<br>
<div style="border:none; border-top:solid #B5C4DF 1.0pt; padding:3.0pt 0cm 0cm 0cm">
<div style="padding-right: 8px; padding-left: 8px; font-size: 12px; font-family: tahoma; color: #000000; background: #efefef; padding-bottom: 8px; padding-top: 8px">
<div><b>发件人:</b> <a href="mailto:%from%" moz-do-not-send="true">%from%</a></div>
<div><b>发送时间:</b> %X:=sent% %Y%-%m%-%d% %H%:%M%:%S%</div>
[[<div><b>收件人:</b> %to(name, bracketMail(angle))%</div>]]
[[<div><b>抄送:</b> %cc(name, bracketMail(angle))%</div>]]
<div><b>主题:</b> %subject%</div>
</div>
</div>

2.2)点“高级” -> “全局设置”。在“邮件内容” -> “顶部换行符数量”,设为“0”。在“高级功能”,取消勾选“插入空格到高亮的光标”、“强制段落模式”。

2.3)点“确定”保存退出。

回复时,除了“收件人”和“抄送”不能定制显示格式,其它都跟Foxmail的一样了。

Play with Fetch

这星期做了个报表,统计一堆关键词在Solr的搜索结果数量。一开始是计划写Java代码去访问Solr,并获取各个关键词的查询结果数量。后来为了减轻服务器压力,把数据下载到本地并重建了Solr索引。那为什么不用简单快速的JavaScript?

JavaScript的数据请求,可用原生的XMLHttpRequest,jQuery的$.ajax,或者fetch。记得N年前看过吹嘘fetch的文章,于是就选了fetch玩玩。该文章如下:

说说感受吧:

1)如果需要“同步”请求,需要配合asyncawait使用,里面还要用到function。一下子不适应,$.ajax只需设置async参数。

2)不支持跨域。尝试按教程去设置fetch请求的Header,仍是不行。简单来说,需要服务端设置可跨域相关。那就简单点,把包含代码的HTML文件丢到Solr的本地站点,然后Chrome访问。

3)Promise语法很新鲜。当然,写得不好,也可以很糟糕。

4)关于错误处理,就不写了,本地请求一般不会出错。

总的来说,没想象中那么牛X,也没那么爽。如果面对一般情况,不想写复杂的XMLHttpRequest,也不想引用庞大的jQuery,不用兼容老版浏览器,fetch是个好家伙。例如这个文章提到:

还有Mozilla的参考教程,说得比较详细: