ZeroNet Blogs

Static ZeroNet blogs mirror

最近几天对 ZeroHello 信息流的混杂,以及站点列表的冗长感到有些不满,试着制作了一个替代品:ZeroGreeter。看起来大概是这个样子的(/・ω・)/

greeter.png (961x773)

不同于默认首页,ZeroGreeter 可以为每个站点设置标签,并根据这些标签分类显示站点列表和信息流。这样,即便添加了较多站点,也可以方便地获取重要的信息流,或找到需求的站点。初始视图中的 vivace 就是一个这样的例子:在传统的 ZeroHello 布局上方,额外添加了博客和论坛的列表,以及单独的提及信息流。这些过滤器的数量、性质和位置,均可以由用户自行配置,以完成最适合自己的首页布局。

除去手动设置,ZeroGreeter 还可以为符合一定条件的站点自动设置标签,例如从 ZeroBlog、ZeroTalk 等特定网站克隆得来的站点,或是被合并的站点。若有收录于使用 Kaffiene Search 格式的索引的站点,也可以从索引中获取标签。这样,即使不经过任何配置,也可以相对有效地使用。不过,如果熟悉一下 local/conf 下的配置文件,或许可以得到更加合适的体验也说不定。配置文件的文档已经嵌入在站点中:点击页面最上方的“Greetings ZeroNet_”并选择 docs 视图即可浏览。

需要注意的是,在使用第三方首页时,必须给予其 ADMIN 权限,以允许查询站点列表和信息流。这是一个非常宽泛的权限,隐含着相当高的信任,不适合随意地给出。因此,ZeroGreeter 鼓励用户查看它的源代码(处在数据文件夹的 src 下),并自行构建私人使用的版本。这很重要,因为即使确信了当前版本的无害,也无法确信同一地址上未来的更新一定是无害的。你应当自行确认并构建每个更新版本。事实上,若你没有将自己设置为站点的所有者,ZeroGreeter 将不会允许你的使用。若你决定使用它,这意味着你以自由心证,确信了内容的无害,并将自己承担这一行为带来的一切风险。

如果能够派上用场的话就太好了!


其他截图

其实已经在 ZeroMe 发过啦。

greeter2.png (944x677) greeter3.png (932x522)

在编写动态站点时,经常需要收集分散在各个 JSON 文件中的数据。为了方便应用,ZeroNet 提供了由描述文件 dbschema.json 控制的数据库机制,但在文档方面,则只有一份代码样例。尽管代码样例经过注释,但许多细节仍未说明,需要阅读源码。现在我把发现写在这里,这样你就不需要费事啦(≧∇≦)b

这篇文章是关于 ZeroNet 0.5.7 master 分支 rev2169 的分析,可能不适用于其他版本。


version 的具体作用

对于 version 这个键的值,文档是这样记述的:

  • 1 = json 表含有同时包含目录名和文件名的 path
  • 2 = json 表含有包含目录名的 directory 列和包含文件名的 file_name
  • 3 = 与 2 相同,但同时含有 site 列(用于 Merger Site)

……是不是不太明白?嗯。我也不太明白。现在看一看对应的实现:

# src/Db/Db.py#L180
# Check json table
if self.schema["version"] == 1:
    changed = cur.needTable("json", [
        ["json_id", "INTEGER PRIMARY KEY AUTOINCREMENT"],
        ["path", "VARCHAR(255)"]
    ], [
        "CREATE UNIQUE INDEX path ON json(path)"
    ], version=self.schema["version"])
elif self.schema["version"] == 2:
    changed = cur.needTable("json", [
        ["json_id", "INTEGER PRIMARY KEY AUTOINCREMENT"],
        ["directory", "VARCHAR(255)"],
        ["file_name", "VARCHAR(255)"]
    ], [
        "CREATE UNIQUE INDEX path ON json(directory, file_name)"
    ], version=self.schema["version"])
elif self.schema["version"] == 3:
    changed = cur.needTable("json", [
        ["json_id", "INTEGER PRIMARY KEY AUTOINCREMENT"],
        ["site", "VARCHAR(255)"],
        ["directory", "VARCHAR(255)"],
        ["file_name", "VARCHAR(255)"]
    ], [
        "CREATE UNIQUE INDEX path ON json(directory, site, file_name)"
    ], version=self.schema["version"])
if changed:
    changed_tables.append("json")

# Check schema tables
for table_name, table_settings in self.schema["tables"].items():
    changed = cur.needTable(
        table_name, table_settings["cols"],
        table_settings["indexes"], version=table_settings["schema_changed"]
    )
    if changed:
        changed_tables.append(table_name)
# src/Db/DbCursor.py#L118
# Get or create a row for json file
# Return: The database row
def getJsonRow(self, file_path):
    directory, file_name = re.match("^(.*?)/*([^/]*)$", file_path).groups()
    if self.db.schema["version"] == 1:
        # One path field
        res = self.execute("SELECT * FROM json WHERE ? LIMIT 1", {"path": file_path})
        row = res.fetchone()
        if not row:  # No row yet, create it
            self.execute("INSERT INTO json ?", {"path": file_path})
            res = self.execute("SELECT * FROM json WHERE ? LIMIT 1", {"path": file_path})
            row = res.fetchone()
    elif self.db.schema["version"] == 2:
        # Separate directory, file_name (easier join)
        res = self.execute("SELECT * FROM json WHERE ? LIMIT 1", {"directory": directory, "file_name": file_name})
        row = res.fetchone()
        if not row:  # No row yet, create it
            self.execute("INSERT INTO json ?", {"directory": directory, "file_name": file_name})
            res = self.execute("SELECT * FROM json WHERE ? LIMIT 1", {"directory": directory, "file_name": file_name})
            row = res.fetchone()
    elif self.db.schema["version"] == 3:
        # Separate site, directory, file_name (for merger sites)
        site_address, directory = re.match("^([^/]*)/(.*)$", directory).groups()
        res = self.execute("SELECT * FROM json WHERE ? LIMIT 1", {"site": site_address, "directory": directory, "file_name": file_name})
        row = res.fetchone()
        if not row:  # No row yet, create it
            self.execute("INSERT INTO json ?", {"site": site_address, "directory": directory, "file_name": file_name})
            res = self.execute("SELECT * FROM json WHERE ? LIMIT 1", {"site": site_address, "directory": directory, "file_name": file_name})
            row = res.fetchone()
    else:
        raise Exception("Dbschema version %s not supported" % self.db.schema.get("version"))
    return row

可以看到,version 的值看上去有两项作用:其一是设置 json 表的结构和索引,其二是决定插入 json 行时的行为:

  • path - 仅在 version 为 1 时设置,包含完整的路径。
  • directory - 在 version 为 2 或 3 时设置,包含文件所在文件夹的路径。不包含最后的分隔符。
  • file_name - 在 version 为 2 或 3 时设置,包含文件名。
  • site - 仅在 version 为 3 时设置,包含文件所在站点的公钥地址。

然而进一步观察,就可以发现 json 表的创建和紧接着的其他数据表的创建均调用了同一个方法 needTable。那么,这个方法又是如何实现的呢?

# src/Db/DbCursor.py#L101
# Create table if not exist
# Return: True if updated
def needTable(self, table, cols, indexes=None, version=1):
    current_version = self.db.getTableVersion(table)
    if int(current_version) < int(version):  # Table need update or not extis
        self.db.log.info("Table %s outdated...version: %s need: %s, rebuilding..." % (table, current_version, version))
        self.createTable(table, cols)
        if indexes:
            self.createIndexes(table, indexes)
        self.execute(
            "INSERT OR REPLACE INTO keyvalue ?",
            {"json_id": 0, "key": "table.%s.version" % table, "value": version}
        )
        return True
    else:  # Not changed
        return False

可以看到,当同名数据表已经存在时,ZeroNet 会判断两者的 version 大小,并决定是否要重建数据表。而对于 json 表而言,这个值或者是 version,或者是自行定义 json 表时设置的 schema_changed。因此,当自行定义了 json 表,且 schema_changed 大于 version 时,自行设置的表结构将会生效,但插入行时仍然将依照 version 设置的行为;相反,若设置的 schema_changed 小于或等于 version自行定义的 json 表结构将不会生效

……依稀记得一开始设置 json 表结构时总是无效,改过几次之后莫名其妙就变得正常了,原来是这样的原理(;´Д`)

keyvalue 表

关于 keyvalue 表和 to_keyvalue 键,文档是这样记述的:

"to_keyvalue": ["next_message_id", "next_topic_id"]
# Load data.json[next_topic_id] to keyvalues table
# (key: next_message_id, value: data.json[next_message_id] value)

……嗯,只有这两句话,而且表名还写错了。总之,看一看实现吧:

# src/Db/Db.py#L168
# Check keyvalue table
changed = cur.needTable("keyvalue", [
    ["keyvalue_id", "INTEGER PRIMARY KEY AUTOINCREMENT"],
    ["key", "TEXT"],
    ["value", "INTEGER"],
    ["json_id", "INTEGER"],
], [
    "CREATE UNIQUE INDEX key_id ON keyvalue(json_id, key)"
], version=self.schema["version"])
if changed:
    changed_tables.append("keyvalue")
# src/Db/Db.py#L273
if dbmap.get("to_keyvalue"):
    # Get current values
    res = cur.execute("SELECT * FROM keyvalue WHERE json_id = ?", (json_row["json_id"],))
    current_keyvalue = {}
    current_keyvalue_id = {}
    for row in res:
        current_keyvalue[row["key"]] = row["value"]
        current_keyvalue_id[row["key"]] = row["keyvalue_id"]

    for key in dbmap["to_keyvalue"]:
        if key not in current_keyvalue:  # Keyvalue not exist yet in the db
            cur.execute(
                "INSERT INTO keyvalue ?",
                {"key": key, "value": data.get(key), "json_id": json_row["json_id"]}
            )
        elif data.get(key) != current_keyvalue[key]:  # Keyvalue different value
            cur.execute(
                "UPDATE keyvalue SET value = ? WHERE keyvalue_id = ?",
                (data.get(key), current_keyvalue_id[key])
            )

首先,和之前的 json 表同样,keyvalue 表的结构版本也等同于 version默认情况下,value 的类型为整数。如果需要 (json_id, key) 之外的索引,或者整型之外的 value 值,可以通过设置大于 versionschema_changed 来覆盖表结构。其次,对于每个 JSON 文件,keyvalue 表中的键是唯一的,且只能等同于文件中的键名。

to_table 的详细规则

关于 to_table 的详细规则,文档是这样记述的:

{
  "node": "comment_votes", # Reading data.json[comment_votes] key value
  "table": "comment_vote", # Feeding data to comment_vote table
  "key_col": "comment_hash",
    # data.json[comment_votes] is a simple dict, the keys of the
    # dict are loaded to comment_vote table comment_hash column
  "val_col": "vote"
    # The data.json[comment_votes] dict values loaded to comment_vote table vote column
}
{
  "node": "includes",
  "table": "user",
  "key_col": "path",
  "import_cols": ["user_id", "user_name", "max_size", "added"],
    # Only import these columns to user table
  "replaces": {
    "path": {"content.json": "data.json"}
      # Replace content.json to data.json in the
      # value of path column (required for joining)
  }
}

嗯……意外地好像说得很清楚?不过为了放心,还是看一下实现吧:

# src/Db/Db.py#L333
if key_col:  # Map as dict
    for key, val in data[node].iteritems():
        if val_col:  # Single value
            cur.execute(
                "INSERT OR REPLACE INTO %s ?" % table_name,
                {key_col: key, val_col: val, "json_id": json_row["json_id"]}
            )
        else:  # Multi value
            if isinstance(val, dict):  # Single row
                row = val
                if import_cols:
                    row = {key: row[key] for key in row if key in import_cols}  # Filter row by import_cols
                row[key_col] = key
                # Replace in value if necessary
                if replaces:
                    for replace_key, replace in replaces.iteritems():
                        if replace_key in row:
                            for replace_from, replace_to in replace.iteritems():
                                row[replace_key] = row[replace_key].replace(replace_from, replace_to)

                row["json_id"] = json_row["json_id"]
                cur.execute("INSERT OR REPLACE INTO %s ?" % table_name, row)
            else:  # Multi row
                for row in val:
                    row[key_col] = key
                    row["json_id"] = json_row["json_id"]
                    cur.execute("INSERT OR REPLACE INTO %s ?" % table_name, row)
else:  # Map as list
    for row in data[node]:
        row["json_id"] = json_row["json_id"]
        if import_cols:
            row = {key: row[key] for key in row if key in import_cols}  # Filter row by import_cols
        cur.execute("INSERT OR REPLACE INTO %s ?" % table_name, row)

(´゚д゚`)

嗯……怎么描述呢,这个情况……

  • 规则中的 node 必须是 JSON 根对象下第一级的键名。
  • 规则中的 import_cols 当且仅当满足如下情况之一时有效:
    • 未设置 key_col
    • 设置了 key_col,且满足如下全部情况:
      • 未设置 val_col
      • JSON 文件中名称等于 node 的值的键的值类型为 JSON Object。
  • 规则中的 replaces 当且仅当满足如下情况时有效:
    • 设置了 key_col
    • 未设置 val_col
    • JSON 文件中名称等于 node 的值的键的值类型为 JSON Object。

大体上而言,只有和代码样例完全相同的用法才能生效。一定要说的话,后两项这样的情况与其说是有意的设计,不如说更像是为了特定用途临时增加功能的产物。ZeroNet 开发者在写下这段代码时是如何思考的,自然无从得知,不过对于希望使用 import_colsreplaces 的用户而言,这应该会成为一个不小的麻烦吧。

就是这样!


附言:也许你已经注意到了,不过表结构的 index 数组中其实是可以填入任意 SQL 语句,使它在创建新表后执行的。可以用来做一些有趣的事情也说不定!

由于 ZeroNet 文档 (clearnet) 中有关用户数据权限的部分语焉不详,不得不阅读 ZeroNet 本身的源码。我把发现写在这里,这样你就不需要费事啦(*´▽`*)

如果你想要在自己的站点配置用户权限规则的话,会有用处也说不定!


permission_rules 直接相关的代码段 (clearnet) 并不难以寻找:

# src/Content/ContentManager.py#L411
for permission_pattern, permission_rules in user_contents["permission_rules"].items():  # Regexp rules
    if not SafeRe.match(permission_pattern, user_urn):
        continue  # Rule is not valid for user
    # Update rules if its better than current recorded ones
    for key, val in permission_rules.iteritems():
        if key not in rules:
            if type(val) is list:
                rules[key] = val[:]  # Make copy
            else:
                rules[key] = val
        elif type(val) is int:  # Int, update if larger
            if val > rules[key]:
                rules[key] = val
        elif hasattr(val, "startswith"):  # String, update if longer
            if len(val) > len(rules[key]):
                rules[key] = val
        elif type(val) is list:  # List, append
            rules[key] += val

根据这段代码,当用户的 ID 符合正则表达式时,ZeroNet 将这样确定用户的权限:

  • 若对应值为整数,取所有值中的最大值。
  • 若对应值为字符串,取所有值中最长的一个。
  • 若对应值为数组,按照规则匹配的顺序进行连接
  • 若对应值为其他类型,取匹配的第一个规则的值,并丢弃之后的所有值

从以上规则中可见,有两项是与遍历顺序相关的。由于 JSON Object 本身在规范中没有被定义顺序,而遍历顺序取决于 JSON 分析器的具体实现,这里就需要寻找读取 JSON 文件的位置:

# src/Site/SiteStorage.py#L4
import json
# src/Site/SiteStorage.py#L252
def loadJson(self, inner_path):
    with self.open(inner_path) as file:
    return json.load(file)

可以看到,ZeroNet 使用了 Python 2 标准库中的 json.load 方法,并没有提供文件名之外的参数。根据 Python 2 文档 (clearnet),可知在没有提供 object_hook 时,JSON Object 被解析为 dict 对象。由于前面的代码中调用了 dict.iteritems 方法,由文档 (clearnet) 进一步得知遍历顺序没有被定义,而且在 CPython 中是“任意但非随机的”。

根据前面的信息,现在可以得到如下结论:

  • 权限的确定类似白名单机制。应该将更高的权限设置在匹配范围更小的正则表达式下。
  • 对于整数类型的权限,取所有值中的最大值。
  • 对于字符串类型的权限,取所有匹配的值中最长的一个。为了保证权限的完整性,更高权限的值应当包含所有更低权限的值。
  • 对于数组类型的权限,取所有匹配的值连接的结果,但顺序不确定。为了避免重复,更高权限的值不应当包含更低权限的值。
  • 对于所有其他类型的权限,只应在匹配范围最广的规则下设置一次,否则执行结果不可预测。

就是这样!

2:3

- Posted in 妖禍子日記 by with comments

值得纪念的第七天!ヾ(。>﹏<。)ノ゙

不过,虽然经过了七天,这里好像还是一直只有关于 0-Gallery 的内容。虽然今天也有一点更新,不过为了避免无聊,今天还是说些其他的事情吧!

毕竟无聊可是人类的敌人。

本季的来自深渊是部有趣的动画。有些仿佛草莓棉花糖的可爱人设,和相比之下庞大地似乎有点不相称的世界观,两者的搭配有足够的新鲜感。加上原作展开十分正经的传言,可能会成为本季最有看点的一部吧!

为了避免无聊,不一直接触新的信息就不行。失去了信息的渠道,人会变成什么样子呢。即便不想考虑,似乎也不行了。

~~虽然雷古的声优也在演唱主题歌,不过果然还是女性的声音啊。这样说的话雷古其实是女孩子的可能性是不是也……在微粒子层面上存在着?!~~

作为刚刚的更新的一部分,SayoGalleryHub 可以克隆啦!现在任何人都可以(大概还算是)方便地在 0-Gallery 上添加图库了。尽管如此,一个几乎空白的总览页面可能还是会让你感到有些困惑——别担心,援军来了(/・ω・)/

7 月 21 日更新:现在可以直接在你拥有的 GalleryHub 网页上添加图库了!如果你的页面上还没有“上传向导”,试试在 ZeroHello 中更新代码吧。不过,手工添加仍然将提供最高图片质量。


需要准备的东西

  • 一个纯文本编辑器
  • 一个图片批处理工具,例如 ImageMagick (clearnet)
  • 一个新克隆的 GalleryHub
  • 一个准备发布的图库
  • 一个可以做种的节点
  • 还有一点耐心!

取个名字吧!

当你打开新克隆的站点,你会看到它有着一个非常平凡的标题和一个名为“Demo Gallery”的图库。你将会注意到,这个图库已经可以在你的 0-Gallery 中浏览了(如果它还没在列表中出现,你可以试试重建数据库)。不过,先不要忙着发布这个新的站点。为了方便区分内容的来源,你首先应该在侧栏为你的站点取一个独特的新名字——一个有关你的 ID 或你将要发布的内容的名字就很不错!

在你保存站点设置并刷新页面后,你就能在页面顶部看到闪闪发亮的新名字了!现在,你可能会想把图库的名字也修改掉。不过,请你稍微沉住气,因为这要稍微多花点工夫……直到你熟悉为止。

找到描述文件

我们知道,ZeroNet 将你访问过的每个站点的数据存放在本地,不过你有没有去实际看过这些文件呢?如果你没有用命令行或配置文件指定其他的位置,ZeroNet 会将数据存储在安装位置的 data 文件夹下。接下来,只要进入以你的新站点的地址为名的子目录,就可以看到如下的文件结构:

  • galleries/[stub]/manifest.json - 图库描述文件
  • galleries/[stub]/thumbs/* - 缩略图
  • galleries/[stub]/img/* - 大分辨率图片(默认为可选文件)
  • 其中 [stub] 为图库识别名,它在你的站点内是唯一的,并会在浏览时出现在 URL 中

现在,重命名 galleries/demo 文件夹,然后用纯文本编辑器打开其中的 manifest.json。你可以这个文件中设定图库的标题、简介、页面顺序、标签等各项元数据。需要注意的是,如果你的 JSON 不符合格式要求,站点就无法检测到图库的存在。因此,如果你还没有接触过 JSON,你可能会希望先看一下它的格式需求 (clearnet)——别担心,这个格式可是以简单闻名的!

这里是一些可能不太明显的栏位的填写方式:

  • lang - ISO 639-1 语言代码的前两个字符。
  • type - 目前,0-Gallery 可以识别 comicdoujinshicollection。如果你的图集带有成人内容,应当加上后缀 -18
  • added - UNIX 时间戳。如果你不知道怎样填写,你可以在浏览器控制台(F12)中输入 (Date.now() / 1000)|0 来获得当前时间。

准备图片

做完修改,保存并刷新,你就能看到填写的信息了。不过,你的图库还有一个显著的问题:最重要的图片不在里面。当然,你可以简单地将图片复制进 img,然后填写 manifest.json 中的 files,不过这可能对读者不太友好,因为越大的原图就需要越长的时间下载。同时,你还需要为各个页面准备缩略图。

由于 0-Gallery 的页面宽度被限制在 1200px,推荐使用如下的大分辨率图片格式:

  • 最大 1200px x 2400px
  • 质量为 80 的 jpg 文件

由于 0-Gallery 的缩略图大小被限制在 192px,推荐使用如下的缩略图格式:

  • 最大 192px x 192px
  • 质量为 70 的 jpg 文件

如果要将文件名形如“image_00.png”、“image_01.png”……“image_40.png”的原图转换为推荐格式,可以使用如下的 ImageMagick 命令行(你也可以使用自己选择的批处理工具):

$ convert 'image_%02d.png[0-40]' -resize \>192x192   -quality 70 '[路径]/thumbs/thumb.jpg'
$ convert 'image_%02d.png[0-40]' -resize \>1200x2400 -quality 80 '[路径]/img/page.jpg'

转换完成后,按顺序填写 manifest.json 中的 filesthumbs,再挑选一个 cover,你的图库页面就会变得热闹多了!

发布站点

终于到了发布的时刻!在签名并发布之前,你应该再次确认描述文件的正确性。你的站点上的“错误检查”工具可以捕捉一些最基本的问题,确认无误后再签名并发布吧!

祝贺!漫长的旅途已经结束……或者……可能是开始也说不定?


写了好久累死啦(´-﹏-`;)

有人感兴趣的话就再好不过了!(树旗)

0-Gallery 的一天

- Posted in 妖禍子日記 by with comments

0-Gallery 是一个图库浏览器。它可以从多个来源查询和展示图库。尽管现在还有些孤单,不过有趣的事就是好事!

在过去的一天里,0-Gallery 发生了这些变化:

  • 增加了查询功能,可以用标签和标题来寻找图库了。
  • 增加了中文本地化,可以依据浏览器设置选择语言了。
  • 增加了缓存开关,点一下就可以开始缓存并帮助分发图库内容。
  • 提升了一点界面可用性。

接下来可能会做也可能不会做的事情:

  • 让图库来源站变得好看一点,并制作方便复制新 Hub 的模板。
  • 写一些文档来协助新来源的创建。
  • 继续寻找分发源码的方式。

……嗯,我就是没有什么可写的事情也要刷一下存在感(/_;)

与语焉不详的文档和人间蒸发的 dbschema 报错信息作战许久,总算写出了一个还算是能用的 Merger Demo。

0-Gallery

ヾ(。>﹏<。)ノ゙✧*。

嗯……就是这样一个非常原始的……本子站。不过这个地址本身并不包含内容,而是像 ZeroMe 一样,可以聚集多个内容来源。既然是在分布式网络上,不废除中心化的权限管理怎么行呢!(≧∇≦)/

当然,现在这里还非常孤单,所以我放了一个(同样非常原始的)内容来源,好让图库有点事做:SayoGalleryHub

暂时就是这样啦!

不知道 ZeroNet 上怎样分发源码比较好呢……

21:6

- Posted in 妖禍子日記 by with comments

经过一晚折腾终于做好了给站点做种的准备。于是先复制一个 Blog 试试吧。

简略地看过一点文档,感到 ZeroNet 有些有趣的地方。今后或许会做一点实验也说不定。

也可能什么都不会做。

竭尽所能抵抗无聊吧(≧∇≦)/