<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://wingu.se/en</id>
    <title>Yingyu Pages (English)</title>
    <updated>2026-06-07T17:13:09.159Z</updated>
    <generator>Feed for Node.js</generator>
    <author>
        <name>Yingyu Cheng</name>
        <email>emerald_cahoots0j@icloud.com</email>
        <uri>https://github.com/winguse</uri>
    </author>
    <link rel="alternate" href="https://wingu.se/en"/>
    <link rel="self" href="https://wingu.se/atom-en.xml"/>
    <subtitle>Yingyu's blog hosted in GitHub Pages (English)</subtitle>
    <icon>https://wingu.se/favicon.ico</icon>
    <rights>All rights reserved, Yingyu Cheng</rights>
    <entry>
        <title type="html"><![CDATA[Changed Location Timeline Tool]]></title>
        <id>https://wingu.se/2026/01/02/location-tracking-solution/en.html</id>
        <link href="https://wingu.se/2026/01/02/location-tracking-solution/en.html"/>
        <updated>2026-01-03T05:05:00.000Z</updated>
        <summary type="html"><![CDATA[
Unnoticeably, time has already slipped into the second day of 2026. I wanted to write something so ...]]></summary>
        <content type="html"><![CDATA[<p><img src="https://winguse.com/view-counter?r=wingu.se/2026/01/02/location-tracking-solution/en&from=feed" style="vertical-align: middle; height: 1em;"/></p>
<p>Unnoticeably, time has already slipped into the second day of 2026. I wanted to write something so that 2025 wouldn't end up without a single article, but alas, I'm lazy.
Recently on holiday at home, I've basically done nothing except tinker with various things, but I haven't even gotten around to redoing the waterproof sealant in the kitchen.
Here's just a summary of my recent journey of tinkering with tools to save my history logs.</p>
<p>At the very beginning, I used Google's Timeline History, which I recall started in 2010. In 2025, this service officially entered Google's graveyard.
I went to export the data once, but unfortunately, before I executed the export, it had already expired my data from the 2010s, so basically nothing was exported.
In 2015, I started using Moves. Later, this app was acquired by Facebook, and in 2018, it also entered Facebook's graveyard.
After tinkering around in 2018, I started using Arc and continued until recently when I switched to Overland.</p>
<p>Arc's data is stored on iCloud. Initially, I was somewhat critical of its requirement to download a model based on my location to identify various types of activities.
But in reality, I didn't care about this identification at all (Moves had it too, and I didn't care then either).
As data accumulated, I encountered issues exporting data multiple times when changing phones.
I always had auto-export of Daily and Monthly GPX and JSON data enabled; even so, it still caused me to lose quite a bit of data. My partner's phone had the same issue.
Arc is a paid app; since I used it, I paid $100 for a lifetime unlock. However, it feels like the app hasn't been very active in updates or bug fixes lately.
Arc also open-sourced its core code once, turning it into Arc Mini, but it seems to have been delisted now. The core code used its own Location library when it was open-sourced, which means it still required downloading its model.
On a "good enough" level, the app is decent, but it clearly doesn't handle large amounts of data management well, and this problem feels like it's snowballing.</p>
<p>In the winter of 2024, I started thinking about a different approach. So I used PostgreSQL to import all Arc data and used Grafana for data visualization; I actually thought it was quite good.
This winter, I saw a <a href="https://dabr.ca/notice/B1YPr3scGjZMCQYdYO" target="_blank" rel="noopener noreferrer">post</a> mentioning a tool called Reitti, and thus began this new round of tinkering.
Reitti requires PostGIS, but it doesn't have a Docker ARM64 image, so I initially gave up. But thinking that I was on holiday and had nothing better to do, I decided to mess with it.
I installed Ubuntu on my only low-power x86 device—a Mi Pad 2 (it took a <em>really</em> long time), then ran it to play around, imported some data, and felt the visualization was quite well done.
At the same time, I also installed a complete OwnTracks solution and played with it throughout the holiday.</p>
<p>Let's talk about the solution I ended up keeping.
I'm currently using the Overland app on my phone, sending data to a server-side written in Deno. The server-side does the following:</p>
<ol>
<li>Writes requests to disk, keeping raw data (learning from OwnTracks Recorder).</li>
<li>Writes to a PostgreSQL data table I created (continuing to use Grafana).</li>
<li>Finally forwards the requests intact to Reitti.</li>
</ol>
<p>OwnTracks actually has its own app, and the server-side is written in C. The entire system design is very restrained, aiming to be like Apple's "Find My," allowing real-time location sharing with friends.
The "restraint" mentioned here is, on one hand, functional simplicity (and thus reliability), and on the other hand, extremely low server-side resource usage.
It initially only supported the MQTT protocol, with HTTP added later. MQTT also uses mTLS authentication, which I found commendable.
However, my personal least favorite part is precisely MQTT. It's a persistent connection, which might make the client more power-hungry than other solutions.
And HTTP mode doesn't seem to support batching, so some people complain about high data consumption.
The server-side philosophy is to preserve data as much as possible, basically writing all received requests as text files. Visualization-wise, it provides a very basic HTML page by default, and there's also a Vue version, but they are only at the "viewable" level, far inferior to the Grafana Dashboards I made.
So I don't plan to use it long-term. But the server-side is indeed very resource-efficient, so I've kept it to see if there will be further developments. Though maybe not; the project is 10 years old, and while still maintained, it's overall very stable and fixed.</p>
<p>Reitti is written in Java and has very high memory consumption, especially during data import and processing. However, it puts a lot of effort into visualization, generating annual and monthly analyses, such as how long was spent in a certain place and what mode of transport was used (judged based on speed).
Its design places high importance on privacy: when converting coordinates to place names, you can download OpenStreetMap offline data for local queries instead of calling external APIs (though to be honest, I took the easy way out and didn't do that).
Reitti only stores 3D coordinate information, and the visualization part only uses 2D data; information like speed isn't saved. So I also don't quite like that it loses so much data (though practically it's not that useful anyway).
Data export is currently quite rudimentary; the export interface will freeze if you select a slightly longer time range. Of course, since it's self-deployed, writing some code to export isn't difficult.
Considering all this, I'm currently just treating it as a visualization UI and not expecting more.
Because I ultimately had to use Reitti, I tinkered for a while to install the database directly outside of Docker. During data import, Reitti even crashed the database process once, but it was manageable after a restart.</p>
<p>Finally, let's talk about the security of the current setup.
Strictly speaking, data security might not be better than Arc. Overland could be killed by iOS, leading to data not being recorded; my home server only backs up every two days—if it doesn't backup in time, data will be lost; the server's own availability isn't high either.
However, Overland saves data locally on the phone first and only deletes it once the server confirms receipt, so it might not be a big issue.
I'm still hoping Overland can support (or another app can support) long-term local storage on the phone.</p>
<p>After writing so much, I'll finally post the code I used, in case anyone finds it useful.
MIT License, I won't put it on GitHub; most of it was written by GPT—I'm really lazy...</p>
<pre data-language="typescript"><code class="language-typescript"><span class="hljs-keyword">import</span> { serve } <span class="hljs-keyword">from</span> <span class="hljs-string">"https://deno.land/std@0.208.0/http/server.ts"</span>;
<span class="hljs-keyword">import</span> { <span class="hljs-title class_">Client</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">"https://deno.land/x/postgres@v0.17.0/mod.ts"</span>;

<span class="hljs-comment">// Type definitions</span>
<span class="hljs-keyword">interface</span> <span class="hljs-title class_">ValidationRule</span> {
  <span class="hljs-attr">userId</span>: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">queries</span>?: <span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">string</span>[]&gt;;
  <span class="hljs-attr">headers</span>?: <span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">string</span>[]&gt;;
}

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">DatabaseConfig</span> {
  <span class="hljs-attr">hostname</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">port</span>: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">database</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">user</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">password</span>: <span class="hljs-built_in">string</span>;
}

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">Config</span> {
  <span class="hljs-attr">port</span>: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">validationRules</span>: <span class="hljs-title class_">ValidationRule</span>[];
  <span class="hljs-attr">database</span>: <span class="hljs-title class_">DatabaseConfig</span>;
  <span class="hljs-attr">fileStoragePath</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">upstreamUri</span>: <span class="hljs-built_in">string</span>;
}

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">LocationProperties</span> {
  <span class="hljs-attr">timestamp</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">latitude</span>?: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">longitude</span>?: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">altitude</span>?: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">speed</span>?: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">horizontal_accuracy</span>?: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">vertical_accuracy</span>?: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">course</span>?: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">course_accuracy</span>?: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">speed_accuracy</span>?: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">motion</span>?: <span class="hljs-built_in">string</span>[];
  <span class="hljs-attr">battery_state</span>?: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">battery_level</span>?: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">wifi</span>?: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">pauses</span>?: <span class="hljs-built_in">boolean</span>;
  <span class="hljs-attr">activity</span>?: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">desired_accuracy</span>?: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">deferred</span>?: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">significant_change</span>?: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">locations_in_payload</span>?: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">device_id</span>?: <span class="hljs-built_in">string</span>;
  <span class="hljs-comment">// Trip-related fields</span>
  <span class="hljs-attr">start</span>?: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">end</span>?: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">type</span>?: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">mode</span>?: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">distance</span>?: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">duration</span>?: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">steps</span>?: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">stopped_automatically</span>?: <span class="hljs-built_in">boolean</span>;
  <span class="hljs-attr">start_location</span>?: <span class="hljs-title class_">Location</span> | <span class="hljs-literal">null</span>;
  <span class="hljs-attr">end_location</span>?: <span class="hljs-title class_">Location</span>;
}

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">PointGeometry</span> {
  <span class="hljs-attr">type</span>: <span class="hljs-string">"Point"</span>;
  <span class="hljs-attr">coordinates</span>: [<span class="hljs-built_in">number</span>, <span class="hljs-built_in">number</span>];
}

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">Location</span> {
  <span class="hljs-attr">type</span>: <span class="hljs-string">"Feature"</span>;
  <span class="hljs-attr">geometry</span>?: <span class="hljs-title class_">PointGeometry</span>;
  <span class="hljs-attr">properties</span>: <span class="hljs-title class_">LocationProperties</span>;
}

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">ApiRequest</span> {
  <span class="hljs-attr">locations</span>: <span class="hljs-title class_">Location</span>[];
  <span class="hljs-attr">current</span>?: <span class="hljs-built_in">unknown</span>;
  <span class="hljs-attr">trip</span>?: <span class="hljs-built_in">unknown</span>;
}

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">DbLocationData</span> {
  <span class="hljs-attr">user_id</span>: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">ts</span>: <span class="hljs-title class_">Date</span>;
  <span class="hljs-attr">latitude</span>: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">longitude</span>: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">horizontal_accuracy</span>: <span class="hljs-built_in">number</span> | <span class="hljs-literal">null</span>;
  <span class="hljs-attr">altitude</span>: <span class="hljs-built_in">number</span> | <span class="hljs-literal">null</span>;
  <span class="hljs-attr">vertical_accuracy</span>: <span class="hljs-built_in">number</span> | <span class="hljs-literal">null</span>;
  <span class="hljs-attr">course</span>: <span class="hljs-built_in">number</span> | <span class="hljs-literal">null</span>;
  <span class="hljs-attr">course_accuracy</span>: <span class="hljs-built_in">number</span> | <span class="hljs-literal">null</span>;
  <span class="hljs-attr">speed</span>: <span class="hljs-built_in">number</span> | <span class="hljs-literal">null</span>;
  <span class="hljs-attr">speed_accuracy</span>: <span class="hljs-built_in">number</span> | <span class="hljs-literal">null</span>;
  <span class="hljs-attr">battery_state</span>: <span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span>;
  <span class="hljs-attr">battery_level</span>: <span class="hljs-built_in">number</span> | <span class="hljs-literal">null</span>;
  <span class="hljs-attr">motions</span>: <span class="hljs-built_in">string</span>[] | <span class="hljs-literal">null</span>;
  <span class="hljs-attr">wifi</span>: <span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span>;
}

<span class="hljs-comment">// Global configuration</span>
<span class="hljs-keyword">const</span> <span class="hljs-attr">CONFIG</span>: <span class="hljs-title class_">Config</span> = {
  <span class="hljs-attr">port</span>: <span class="hljs-built_in">parseInt</span>(<span class="hljs-title class_">Deno</span>.<span class="hljs-property">env</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">"SERVER_PORT"</span>) || <span class="hljs-string">"8080"</span>),
  <span class="hljs-attr">validationRules</span>: [
    {
      <span class="hljs-attr">userId</span>: <span class="hljs-number">1</span>,
      <span class="hljs-attr">queries</span>: { <span class="hljs-attr">token</span>: [<span class="hljs-string">"user 1 token"</span>] },
      <span class="hljs-comment">// headers: { headerName: ["validValue3"] },</span>
    },
    {
      <span class="hljs-attr">userId</span>: <span class="hljs-number">2</span>,
      <span class="hljs-attr">queries</span>: { <span class="hljs-attr">token</span>: [<span class="hljs-string">"user 2 token"</span>] },
      <span class="hljs-comment">// headers: { headerName: ["validValue3"] },</span>
    },
    <span class="hljs-comment">// Add more rules as needed</span>
  ],
  <span class="hljs-attr">database</span>: {
    <span class="hljs-attr">hostname</span>: <span class="hljs-title class_">Deno</span>.<span class="hljs-property">env</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">"DB_HOSTNAME"</span>) || <span class="hljs-string">"localhost"</span>,
    <span class="hljs-attr">port</span>: <span class="hljs-built_in">parseInt</span>(<span class="hljs-title class_">Deno</span>.<span class="hljs-property">env</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">"DB_PORT"</span>) || <span class="hljs-string">"5432"</span>),
    <span class="hljs-attr">database</span>: <span class="hljs-title class_">Deno</span>.<span class="hljs-property">env</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">"DB_NAME"</span>) || <span class="hljs-string">"location_history"</span>,
    <span class="hljs-attr">user</span>: <span class="hljs-title class_">Deno</span>.<span class="hljs-property">env</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">"DB_USER"</span>) || <span class="hljs-string">"postgres"</span>,
    <span class="hljs-attr">password</span>: <span class="hljs-title class_">Deno</span>.<span class="hljs-property">env</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">"DB_PASSWORD"</span>) || <span class="hljs-string">""</span>,
  },
  <span class="hljs-attr">fileStoragePath</span>: <span class="hljs-string">"/app/data"</span>,
  <span class="hljs-attr">upstreamUri</span>: <span class="hljs-string">"http://reitti-server-address:8080/api/v1/ingest/overland"</span>,
};

<span class="hljs-comment">// Database client</span>
<span class="hljs-keyword">let</span> <span class="hljs-attr">dbClient</span>: <span class="hljs-title class_">Client</span> | <span class="hljs-literal">null</span> = <span class="hljs-literal">null</span>;

<span class="hljs-comment">// Initialize database connection</span>
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">initDatabase</span>(<span class="hljs-params"></span>) {
  dbClient = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Client</span>(<span class="hljs-variable constant_">CONFIG</span>.<span class="hljs-property">database</span>);
  <span class="hljs-keyword">await</span> dbClient.<span class="hljs-title function_">connect</span>();
  <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-string">"Database connected"</span>);
}

<span class="hljs-comment">// Validate request against rules</span>
<span class="hljs-keyword">function</span> <span class="hljs-title function_">validateRequest</span>(<span class="hljs-params">
  <span class="hljs-attr">url</span>: URL,
  <span class="hljs-attr">headers</span>: <span class="hljs-title class_">Headers</span>,
</span>): { <span class="hljs-attr">valid</span>: <span class="hljs-built_in">boolean</span>; <span class="hljs-attr">userId</span>?: <span class="hljs-built_in">number</span> } {
  <span class="hljs-keyword">const</span> queryParams = <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">fromEntries</span>(url.<span class="hljs-property">searchParams</span>.<span class="hljs-title function_">entries</span>());
  <span class="hljs-keyword">const</span> headerMap = <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">fromEntries</span>(headers.<span class="hljs-title function_">entries</span>());

  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> rule <span class="hljs-keyword">of</span> <span class="hljs-variable constant_">CONFIG</span>.<span class="hljs-property">validationRules</span>) {
    <span class="hljs-keyword">let</span> matches = <span class="hljs-literal">true</span>;

    <span class="hljs-comment">// Check queries</span>
    <span class="hljs-keyword">if</span> (rule.<span class="hljs-property">queries</span>) {
      <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> [key, validValues] <span class="hljs-keyword">of</span> <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">entries</span>(rule.<span class="hljs-property">queries</span>)) {
        <span class="hljs-keyword">const</span> value = queryParams[key];
        <span class="hljs-keyword">if</span> (!value || !validValues.<span class="hljs-title function_">includes</span>(value)) {
          matches = <span class="hljs-literal">false</span>;
          <span class="hljs-keyword">break</span>;
        }
      }
    }

    <span class="hljs-comment">// Check headers</span>
    <span class="hljs-keyword">if</span> (matches &amp;&amp; rule.<span class="hljs-property">headers</span>) {
      <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> [key, validValues] <span class="hljs-keyword">of</span> <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">entries</span>(rule.<span class="hljs-property">headers</span>)) {
        <span class="hljs-keyword">const</span> value = headerMap[key.<span class="hljs-title function_">toLowerCase</span>()];
        <span class="hljs-keyword">if</span> (!value || !validValues.<span class="hljs-title function_">includes</span>(value)) {
          matches = <span class="hljs-literal">false</span>;
          <span class="hljs-keyword">break</span>;
        }
      }
    }

    <span class="hljs-keyword">if</span> (matches) {
      <span class="hljs-keyword">return</span> { <span class="hljs-attr">valid</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">userId</span>: rule.<span class="hljs-property">userId</span> };
    }
  }

  <span class="hljs-keyword">return</span> { <span class="hljs-attr">valid</span>: <span class="hljs-literal">false</span> };
}

<span class="hljs-comment">// Validate location data</span>
<span class="hljs-keyword">function</span> <span class="hljs-title function_">validateLocation</span>(<span class="hljs-params"><span class="hljs-attr">location</span>: <span class="hljs-title class_">Location</span></span>): {
  <span class="hljs-attr">valid</span>: <span class="hljs-built_in">boolean</span>;
  <span class="hljs-attr">error</span>?: <span class="hljs-built_in">string</span>;
} {
  <span class="hljs-keyword">if</span> (!location.<span class="hljs-property">properties</span>) {
    <span class="hljs-keyword">return</span> { <span class="hljs-attr">valid</span>: <span class="hljs-literal">false</span>, <span class="hljs-attr">error</span>: <span class="hljs-string">"Missing properties"</span> };
  }

  <span class="hljs-keyword">const</span> props = location.<span class="hljs-property">properties</span>;
  
  <span class="hljs-comment">// For trip records, timestamp might be in 'end' field, or use 'timestamp'</span>
  <span class="hljs-keyword">const</span> hasTimestamp = props.<span class="hljs-property">timestamp</span> || props.<span class="hljs-property">end</span> || props.<span class="hljs-property">start</span>;
  <span class="hljs-keyword">if</span> (!hasTimestamp) {
    <span class="hljs-keyword">return</span> { <span class="hljs-attr">valid</span>: <span class="hljs-literal">false</span>, <span class="hljs-attr">error</span>: <span class="hljs-string">"Missing timestamp"</span> };
  }

  <span class="hljs-comment">// Check for coordinates in geometry.coordinates or properties</span>
  <span class="hljs-keyword">const</span> coords = location.<span class="hljs-property">geometry</span>?.<span class="hljs-property">coordinates</span> || [];
  <span class="hljs-keyword">const</span> hasCoordsInGeometry = coords.<span class="hljs-property">length</span> &gt;= <span class="hljs-number">2</span> &amp;&amp; 
    coords[<span class="hljs-number">0</span>] !== <span class="hljs-literal">undefined</span> &amp;&amp; coords[<span class="hljs-number">0</span>] !== <span class="hljs-literal">null</span> &amp;&amp;
    coords[<span class="hljs-number">1</span>] !== <span class="hljs-literal">undefined</span> &amp;&amp; coords[<span class="hljs-number">1</span>] !== <span class="hljs-literal">null</span>;
  <span class="hljs-keyword">const</span> hasCoordsInProps = props.<span class="hljs-property">latitude</span> !== <span class="hljs-literal">undefined</span> &amp;&amp; props.<span class="hljs-property">latitude</span> !== <span class="hljs-literal">null</span> &amp;&amp;
    props.<span class="hljs-property">longitude</span> !== <span class="hljs-literal">undefined</span> &amp;&amp; props.<span class="hljs-property">longitude</span> !== <span class="hljs-literal">null</span>;

  <span class="hljs-keyword">if</span> (!hasCoordsInGeometry &amp;&amp; !hasCoordsInProps) {
    <span class="hljs-keyword">return</span> { <span class="hljs-attr">valid</span>: <span class="hljs-literal">false</span>, <span class="hljs-attr">error</span>: <span class="hljs-string">"Missing latitude/longitude"</span> };
  }

  <span class="hljs-keyword">return</span> { <span class="hljs-attr">valid</span>: <span class="hljs-literal">true</span> };
}

<span class="hljs-comment">// Convert location to database format</span>
<span class="hljs-keyword">function</span> <span class="hljs-title function_">locationToDbFormat</span>(<span class="hljs-params"><span class="hljs-attr">location</span>: <span class="hljs-title class_">Location</span>, <span class="hljs-attr">userId</span>: <span class="hljs-built_in">number</span></span>): <span class="hljs-title class_">DbLocationData</span> {
  <span class="hljs-keyword">const</span> props = location.<span class="hljs-property">properties</span>;
  <span class="hljs-keyword">const</span> coords = location.<span class="hljs-property">geometry</span>?.<span class="hljs-property">coordinates</span> || [];

  <span class="hljs-comment">// Use timestamp, end, or start (in that order of preference)</span>
  <span class="hljs-keyword">const</span> timestampStr = props.<span class="hljs-property">timestamp</span> || props.<span class="hljs-property">end</span> || props.<span class="hljs-property">start</span>;
  <span class="hljs-keyword">if</span> (!timestampStr) {
    <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">"No timestamp available"</span>);
  }
  <span class="hljs-keyword">const</span> timestamp = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>(timestampStr);
  <span class="hljs-keyword">const</span> latitude = coords[<span class="hljs-number">1</span>] ?? props.<span class="hljs-property">latitude</span>;
  <span class="hljs-keyword">const</span> longitude = coords[<span class="hljs-number">0</span>] ?? props.<span class="hljs-property">longitude</span>;

  <span class="hljs-comment">// Enum values from schema</span>
  <span class="hljs-keyword">const</span> validMotionTypes = [
    <span class="hljs-string">"driving"</span>,
    <span class="hljs-string">"walking"</span>,
    <span class="hljs-string">"running"</span>,
    <span class="hljs-string">"cycling"</span>,
    <span class="hljs-string">"stationary"</span>,
    <span class="hljs-string">"automotive_navigation"</span>,
    <span class="hljs-string">"fitness"</span>,
    <span class="hljs-string">"other_navigation"</span>,
    <span class="hljs-string">"other"</span>,
    <span class="hljs-string">"moving"</span>,
    <span class="hljs-string">"uncertain"</span>,
  ];
  <span class="hljs-keyword">const</span> validBatteryStates = [<span class="hljs-string">"unknown"</span>, <span class="hljs-string">"charging"</span>, <span class="hljs-string">"full"</span>, <span class="hljs-string">"unplugged"</span>];

  <span class="hljs-comment">// Process motion array</span>
  <span class="hljs-keyword">let</span> <span class="hljs-attr">motions</span>: <span class="hljs-built_in">string</span>[] | <span class="hljs-literal">null</span> = <span class="hljs-literal">null</span>;
  <span class="hljs-keyword">if</span> (<span class="hljs-title class_">Array</span>.<span class="hljs-title function_">isArray</span>(props.<span class="hljs-property">motion</span>)) {
    motions = props.<span class="hljs-property">motion</span>.<span class="hljs-title function_">filter</span>(<span class="hljs-function">(<span class="hljs-params">m</span>) =&gt;</span>
      validMotionTypes.<span class="hljs-title function_">includes</span>(m)
    );
    <span class="hljs-keyword">if</span> (motions.<span class="hljs-property">length</span> === <span class="hljs-number">0</span>) {
      motions = <span class="hljs-literal">null</span>;
    }
  }

  <span class="hljs-comment">// Process battery_state</span>
  <span class="hljs-keyword">let</span> <span class="hljs-attr">batteryState</span>: <span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span> = props.<span class="hljs-property">battery_state</span> || <span class="hljs-literal">null</span>;
  <span class="hljs-keyword">if</span> (batteryState &amp;&amp; !validBatteryStates.<span class="hljs-title function_">includes</span>(batteryState)) {
    batteryState = <span class="hljs-literal">null</span>;
  }

  <span class="hljs-keyword">return</span> {
    <span class="hljs-attr">user_id</span>: userId,
    <span class="hljs-attr">ts</span>: timestamp,
    <span class="hljs-attr">latitude</span>: latitude!,
    <span class="hljs-attr">longitude</span>: longitude!,
    <span class="hljs-attr">horizontal_accuracy</span>: props.<span class="hljs-property">horizontal_accuracy</span> ?? <span class="hljs-literal">null</span>,
    <span class="hljs-attr">altitude</span>: props.<span class="hljs-property">altitude</span> ?? <span class="hljs-literal">null</span>,
    <span class="hljs-attr">vertical_accuracy</span>: props.<span class="hljs-property">vertical_accuracy</span> ?? <span class="hljs-literal">null</span>,
    <span class="hljs-attr">course</span>: props.<span class="hljs-property">course</span> ?? <span class="hljs-literal">null</span>,
    <span class="hljs-attr">course_accuracy</span>: props.<span class="hljs-property">course_accuracy</span> ?? <span class="hljs-literal">null</span>,
    <span class="hljs-attr">speed</span>: props.<span class="hljs-property">speed</span> ?? <span class="hljs-literal">null</span>,
    <span class="hljs-attr">speed_accuracy</span>: props.<span class="hljs-property">speed_accuracy</span> ?? <span class="hljs-literal">null</span>,
    <span class="hljs-attr">battery_state</span>: batteryState,
    <span class="hljs-attr">battery_level</span>: props.<span class="hljs-property">battery_level</span> ?? <span class="hljs-literal">null</span>,
    <span class="hljs-attr">motions</span>: motions,
    <span class="hljs-attr">wifi</span>: props.<span class="hljs-property">wifi</span> || <span class="hljs-literal">null</span>,
  };
}

<span class="hljs-comment">// Insert a single location into database</span>
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">insertSingleLocation</span>(<span class="hljs-params"><span class="hljs-attr">location</span>: <span class="hljs-title class_">Location</span>, <span class="hljs-attr">userId</span>: <span class="hljs-built_in">number</span></span>) {
  <span class="hljs-keyword">if</span> (!dbClient) {
    <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">"Database not connected"</span>);
  }

  <span class="hljs-keyword">const</span> validation = <span class="hljs-title function_">validateLocation</span>(location);
  <span class="hljs-keyword">if</span> (!validation.<span class="hljs-property">valid</span>) {
    <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">warn</span>(<span class="hljs-string">`Skipping invalid location: <span class="hljs-subst">${validation.error}</span>`</span>);
    <span class="hljs-keyword">return</span>;
  }

  <span class="hljs-keyword">const</span> dbData = <span class="hljs-title function_">locationToDbFormat</span>(location, userId);

  <span class="hljs-keyword">await</span> dbClient.<span class="hljs-property">queryObject</span><span class="hljs-string">`
    INSERT INTO public.positions (
      user_id, ts, geom, horizontal_accuracy,
      altitude, vertical_accuracy, course, course_accuracy,
      speed, speed_accuracy, battery_state, battery_level,
      motions, wifi
    ) VALUES (
      <span class="hljs-subst">${dbData.user_id}</span>, <span class="hljs-subst">${dbData.ts}</span>,
      ST_SetSRID(ST_MakePoint(<span class="hljs-subst">${dbData.longitude}</span>, <span class="hljs-subst">${dbData.latitude}</span>), 4326),
      <span class="hljs-subst">${dbData.horizontal_accuracy}</span>, <span class="hljs-subst">${dbData.altitude}</span>, <span class="hljs-subst">${dbData.vertical_accuracy}</span>,
      <span class="hljs-subst">${dbData.course}</span>, <span class="hljs-subst">${dbData.course_accuracy}</span>, <span class="hljs-subst">${dbData.speed}</span>,
      <span class="hljs-subst">${dbData.speed_accuracy}</span>, <span class="hljs-subst">${dbData.battery_state}</span>, <span class="hljs-subst">${dbData.battery_level}</span>,
      <span class="hljs-subst">${dbData.motions}</span>, <span class="hljs-subst">${dbData.wifi}</span>
    )
    ON CONFLICT (ts, user_id, geom) DO UPDATE SET
      horizontal_accuracy = EXCLUDED.horizontal_accuracy,
      altitude = EXCLUDED.altitude,
      vertical_accuracy = EXCLUDED.vertical_accuracy,
      course = EXCLUDED.course,
      course_accuracy = EXCLUDED.course_accuracy,
      speed = EXCLUDED.speed,
      speed_accuracy = EXCLUDED.speed_accuracy,
      battery_state = EXCLUDED.battery_state,
      battery_level = EXCLUDED.battery_level,
      motions = EXCLUDED.motions,
      wifi = EXCLUDED.wifi
  `</span>;
}

<span class="hljs-comment">// Extract all locations from a location object, including nested ones</span>
<span class="hljs-keyword">function</span> <span class="hljs-title function_">extractAllLocations</span>(<span class="hljs-params"><span class="hljs-attr">location</span>: <span class="hljs-title class_">Location</span></span>): <span class="hljs-title class_">Location</span>[] {
  <span class="hljs-keyword">const</span> <span class="hljs-attr">result</span>: <span class="hljs-title class_">Location</span>[] = [];
  <span class="hljs-keyword">const</span> props = location.<span class="hljs-property">properties</span>;

  <span class="hljs-comment">// Extract start_location if it exists</span>
  <span class="hljs-keyword">if</span> (props.<span class="hljs-property">start_location</span>) {
    result.<span class="hljs-title function_">push</span>(props.<span class="hljs-property">start_location</span>);
  }

  <span class="hljs-comment">// Extract the main location if it has valid coordinates</span>
  <span class="hljs-keyword">if</span> (location.<span class="hljs-property">geometry</span>?.<span class="hljs-property">coordinates</span> || props.<span class="hljs-property">latitude</span> !== <span class="hljs-literal">undefined</span>) {
    result.<span class="hljs-title function_">push</span>(location);
  }

  <span class="hljs-comment">// Extract end_location if it exists</span>
  <span class="hljs-keyword">if</span> (props.<span class="hljs-property">end_location</span>) {
    result.<span class="hljs-title function_">push</span>(props.<span class="hljs-property">end_location</span>);
  }

  <span class="hljs-keyword">return</span> result;
}

<span class="hljs-comment">// Insert locations into database</span>
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">insertLocations</span>(<span class="hljs-params"><span class="hljs-attr">locations</span>: <span class="hljs-title class_">Location</span>[], <span class="hljs-attr">userId</span>: <span class="hljs-built_in">number</span></span>) {
  <span class="hljs-keyword">if</span> (!dbClient) {
    <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">"Database not connected"</span>);
  }

  <span class="hljs-comment">// Extract all locations (including nested ones) into a flat list</span>
  <span class="hljs-keyword">const</span> <span class="hljs-attr">allLocations</span>: <span class="hljs-title class_">Location</span>[] = [];
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> location <span class="hljs-keyword">of</span> locations) {
    <span class="hljs-keyword">const</span> extracted = <span class="hljs-title function_">extractAllLocations</span>(location);
    allLocations.<span class="hljs-title function_">push</span>(...extracted);
  }

  <span class="hljs-comment">// Insert each location individually as a separate record</span>
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> location <span class="hljs-keyword">of</span> allLocations) {
    <span class="hljs-keyword">await</span> <span class="hljs-title function_">insertSingleLocation</span>(location, userId);
  }
}

<span class="hljs-comment">// Write JSON to disk</span>
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">writeToDisk</span>(<span class="hljs-params"><span class="hljs-attr">jsonData</span>: <span class="hljs-title class_">ApiRequest</span>, <span class="hljs-attr">userId</span>: <span class="hljs-built_in">number</span></span>) {
  <span class="hljs-keyword">const</span> now = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>();
  <span class="hljs-keyword">const</span> year = now.<span class="hljs-title function_">getUTCFullYear</span>();
  <span class="hljs-keyword">const</span> month = <span class="hljs-title class_">String</span>(now.<span class="hljs-title function_">getUTCMonth</span>() + <span class="hljs-number">1</span>).<span class="hljs-title function_">padStart</span>(<span class="hljs-number">2</span>, <span class="hljs-string">"0"</span>);
  <span class="hljs-keyword">const</span> day = <span class="hljs-title class_">String</span>(now.<span class="hljs-title function_">getUTCDate</span>()).<span class="hljs-title function_">padStart</span>(<span class="hljs-number">2</span>, <span class="hljs-string">"0"</span>);

  <span class="hljs-keyword">const</span> dirPath = <span class="hljs-string">`<span class="hljs-subst">${CONFIG.fileStoragePath}</span>/<span class="hljs-subst">${year}</span>/<span class="hljs-subst">${month}</span>`</span>;
  <span class="hljs-keyword">await</span> <span class="hljs-title class_">Deno</span>.<span class="hljs-title function_">mkdir</span>(dirPath, { <span class="hljs-attr">recursive</span>: <span class="hljs-literal">true</span> });

  <span class="hljs-keyword">const</span> filePath = <span class="hljs-string">`<span class="hljs-subst">${dirPath}</span>/<span class="hljs-subst">${day}</span>.rec`</span>;
  <span class="hljs-keyword">const</span> jsonStr = <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(jsonData);
  <span class="hljs-keyword">const</span> line = <span class="hljs-string">`<span class="hljs-subst">${userId}</span> <span class="hljs-subst">${jsonStr}</span>\n`</span>;

  <span class="hljs-keyword">await</span> <span class="hljs-title class_">Deno</span>.<span class="hljs-title function_">writeTextFile</span>(filePath, line, { <span class="hljs-attr">append</span>: <span class="hljs-literal">true</span> });
}

<span class="hljs-comment">// Forward request to upstream</span>
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">forwardRequest</span>(<span class="hljs-params">
  <span class="hljs-attr">originalRequest</span>: <span class="hljs-title class_">Request</span>,
  <span class="hljs-attr">jsonData</span>: <span class="hljs-title class_">ApiRequest</span>,
</span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">Response</span>&gt; {
  <span class="hljs-keyword">const</span> upstreamUrl = <span class="hljs-keyword">new</span> <span class="hljs-title function_">URL</span>(<span class="hljs-variable constant_">CONFIG</span>.<span class="hljs-property">upstreamUri</span>);

  <span class="hljs-comment">// Copy query parameters</span>
  <span class="hljs-keyword">const</span> originalUrl = <span class="hljs-keyword">new</span> <span class="hljs-title function_">URL</span>(originalRequest.<span class="hljs-property">url</span>);
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> [key, value] <span class="hljs-keyword">of</span> originalUrl.<span class="hljs-property">searchParams</span>.<span class="hljs-title function_">entries</span>()) {
    upstreamUrl.<span class="hljs-property">searchParams</span>.<span class="hljs-title function_">set</span>(key, value);
  }

  <span class="hljs-comment">// Copy headers</span>
  <span class="hljs-keyword">const</span> headers = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Headers</span>();
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> [key, value] <span class="hljs-keyword">of</span> originalRequest.<span class="hljs-property">headers</span>.<span class="hljs-title function_">entries</span>()) {
    headers.<span class="hljs-title function_">set</span>(key, value);
  }
  <span class="hljs-comment">// Ensure Content-Type is set for JSON</span>
  <span class="hljs-keyword">if</span> (!headers.<span class="hljs-title function_">has</span>(<span class="hljs-string">"Content-Type"</span>)) {
    headers.<span class="hljs-title function_">set</span>(<span class="hljs-string">"Content-Type"</span>, <span class="hljs-string">"application/json"</span>);
  }

  <span class="hljs-comment">// Forward request</span>
  <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(upstreamUrl.<span class="hljs-title function_">toString</span>(), {
    <span class="hljs-attr">method</span>: <span class="hljs-string">"POST"</span>,
    <span class="hljs-attr">headers</span>: headers,
    <span class="hljs-attr">body</span>: <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(jsonData),
  });

  <span class="hljs-keyword">return</span> response;
}

<span class="hljs-comment">// Handle API request</span>
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">handleApiRequest</span>(<span class="hljs-params"><span class="hljs-attr">request</span>: <span class="hljs-title class_">Request</span></span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">Response</span>&gt; {
  <span class="hljs-keyword">try</span> {
    <span class="hljs-comment">// Validate request</span>
    <span class="hljs-keyword">const</span> url = <span class="hljs-keyword">new</span> <span class="hljs-title function_">URL</span>(request.<span class="hljs-property">url</span>);
    <span class="hljs-keyword">const</span> validation = <span class="hljs-title function_">validateRequest</span>(url, request.<span class="hljs-property">headers</span>);

    <span class="hljs-keyword">if</span> (!validation.<span class="hljs-property">valid</span>) {
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
        <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">"Validation failed"</span> }),
        { <span class="hljs-attr">status</span>: <span class="hljs-number">403</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
      );
    }

    <span class="hljs-keyword">const</span> userId = validation.<span class="hljs-property">userId</span>!;

    <span class="hljs-comment">// Parse JSON</span>
    <span class="hljs-keyword">let</span> <span class="hljs-attr">jsonData</span>: <span class="hljs-title class_">ApiRequest</span>;
    <span class="hljs-keyword">try</span> {
      jsonData = <span class="hljs-keyword">await</span> request.<span class="hljs-title function_">json</span>() <span class="hljs-keyword">as</span> <span class="hljs-title class_">ApiRequest</span>;
    } <span class="hljs-keyword">catch</span> (error) {
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
        <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">"Invalid JSON"</span>, <span class="hljs-attr">details</span>: <span class="hljs-title class_">String</span>(error) }),
        { <span class="hljs-attr">status</span>: <span class="hljs-number">400</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
      );
    }

    <span class="hljs-comment">// Validate locations array exists</span>
    <span class="hljs-keyword">if</span> (!jsonData.<span class="hljs-property">locations</span> || !<span class="hljs-title class_">Array</span>.<span class="hljs-title function_">isArray</span>(jsonData.<span class="hljs-property">locations</span>)) {
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
        <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">"Missing or invalid locations array"</span> }),
        { <span class="hljs-attr">status</span>: <span class="hljs-number">400</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
      );
    }

    <span class="hljs-comment">// always forward first</span>
    <span class="hljs-keyword">const</span> fwRequested = <span class="hljs-title function_">forwardRequest</span>(request, jsonData);

    <span class="hljs-comment">// Write to disk</span>
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">await</span> <span class="hljs-title function_">writeToDisk</span>(jsonData, userId);
    } <span class="hljs-keyword">catch</span> (error) {
      <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">error</span>(<span class="hljs-string">"File write error:"</span>, error);
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
        <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">"Write file error"</span>, <span class="hljs-attr">details</span>: <span class="hljs-title class_">String</span>(error) }),
        { <span class="hljs-attr">status</span>: <span class="hljs-number">500</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
      );
    }

    <span class="hljs-comment">// Write to database</span>
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">await</span> <span class="hljs-title function_">insertLocations</span>(jsonData.<span class="hljs-property">locations</span>, userId);
    } <span class="hljs-keyword">catch</span> (error) {
      <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">error</span>(<span class="hljs-string">"Database error:"</span>, error);
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
        <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">"Database error"</span>, <span class="hljs-attr">details</span>: <span class="hljs-title class_">String</span>(error) }),
        { <span class="hljs-attr">status</span>: <span class="hljs-number">500</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
      );
    }

    <span class="hljs-comment">// Wait Forward to upstream</span>
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">const</span> upstreamResponse = <span class="hljs-keyword">await</span> fwRequested;
      <span class="hljs-keyword">return</span> upstreamResponse;
    } <span class="hljs-keyword">catch</span> (error) {
      <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">error</span>(<span class="hljs-string">"Upstream forward error:"</span>, error);
      <span class="hljs-comment">// Return success even if upstream fails</span>
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
        <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">success</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">message</span>: <span class="hljs-string">"Processed but upstream failed"</span> }),
        { <span class="hljs-attr">status</span>: <span class="hljs-number">500</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
      );
    }
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">error</span>(<span class="hljs-string">"Unexpected error:"</span>, error);
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
      <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">"Internal server error"</span>, <span class="hljs-attr">details</span>: <span class="hljs-title class_">String</span>(error) }),
      { <span class="hljs-attr">status</span>: <span class="hljs-number">500</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
    );
  }
}

<span class="hljs-comment">// Forward reprocessed data to upstream (no original Request available)</span>
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">forwardReprocessRequest</span>(<span class="hljs-params">
  <span class="hljs-attr">jsonData</span>: <span class="hljs-title class_">ApiRequest</span>,
  <span class="hljs-attr">userId</span>: <span class="hljs-built_in">number</span>,
</span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">Response</span>&gt; {
  <span class="hljs-keyword">const</span> upstreamUrl = <span class="hljs-keyword">new</span> <span class="hljs-title function_">URL</span>(<span class="hljs-variable constant_">CONFIG</span>.<span class="hljs-property">upstreamUri</span>);

  <span class="hljs-comment">// Re-apply validation rule query params (e.g. token)</span>
  <span class="hljs-keyword">const</span> rule = <span class="hljs-variable constant_">CONFIG</span>.<span class="hljs-property">validationRules</span>.<span class="hljs-title function_">find</span>(<span class="hljs-function"><span class="hljs-params">r</span> =&gt;</span> r.<span class="hljs-property">userId</span> === userId);
  <span class="hljs-keyword">if</span> (rule?.<span class="hljs-property">queries</span>) {
    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> [key, values] <span class="hljs-keyword">of</span> <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">entries</span>(rule.<span class="hljs-property">queries</span>)) {
      <span class="hljs-keyword">if</span> (values.<span class="hljs-property">length</span> &gt; <span class="hljs-number">0</span>) {
        upstreamUrl.<span class="hljs-property">searchParams</span>.<span class="hljs-title function_">set</span>(key, values[<span class="hljs-number">0</span>]);
      }
    }
  }

  <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(upstreamUrl.<span class="hljs-title function_">toString</span>(), {
    <span class="hljs-attr">method</span>: <span class="hljs-string">"POST"</span>,
    <span class="hljs-attr">headers</span>: {
      <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span>,
    },
    <span class="hljs-attr">body</span>: <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(jsonData),
  });
}



<span class="hljs-comment">// Reprocess data from rec file for a given date</span>
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">handleReprocessRequest</span>(<span class="hljs-params"><span class="hljs-attr">request</span>: <span class="hljs-title class_">Request</span>, <span class="hljs-attr">dateStr</span>: <span class="hljs-built_in">string</span></span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">Response</span>&gt; {
  <span class="hljs-keyword">try</span> {

    <span class="hljs-comment">// Parse date (expecting YYYY-MM-DD format)</span>
    <span class="hljs-keyword">let</span> <span class="hljs-attr">date</span>: <span class="hljs-title class_">Date</span>;
    <span class="hljs-keyword">try</span> {
      date = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>(dateStr + <span class="hljs-string">"T00:00:00Z"</span>);
      <span class="hljs-keyword">if</span> (<span class="hljs-built_in">isNaN</span>(date.<span class="hljs-title function_">getTime</span>())) {
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">"Invalid date format"</span>);
      }
    } <span class="hljs-keyword">catch</span> (error) {
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
        <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">"Invalid date format. Expected YYYY-MM-DD"</span>, <span class="hljs-attr">details</span>: <span class="hljs-title class_">String</span>(error) }),
        { <span class="hljs-attr">status</span>: <span class="hljs-number">400</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
      );
    }

    <span class="hljs-keyword">const</span> year = date.<span class="hljs-title function_">getUTCFullYear</span>();
    <span class="hljs-keyword">const</span> month = <span class="hljs-title class_">String</span>(date.<span class="hljs-title function_">getUTCMonth</span>() + <span class="hljs-number">1</span>).<span class="hljs-title function_">padStart</span>(<span class="hljs-number">2</span>, <span class="hljs-string">"0"</span>);
    <span class="hljs-keyword">const</span> day = <span class="hljs-title class_">String</span>(date.<span class="hljs-title function_">getUTCDate</span>()).<span class="hljs-title function_">padStart</span>(<span class="hljs-number">2</span>, <span class="hljs-string">"0"</span>);

    <span class="hljs-keyword">const</span> filePath = <span class="hljs-string">`<span class="hljs-subst">${CONFIG.fileStoragePath}</span>/<span class="hljs-subst">${year}</span>/<span class="hljs-subst">${month}</span>/<span class="hljs-subst">${day}</span>.rec`</span>;

    <span class="hljs-comment">// Check if file exists</span>
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">await</span> <span class="hljs-title class_">Deno</span>.<span class="hljs-title function_">stat</span>(filePath);
    } <span class="hljs-keyword">catch</span> (error) {
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
        <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">"File not found"</span>, <span class="hljs-attr">path</span>: filePath }),
        { <span class="hljs-attr">status</span>: <span class="hljs-number">404</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
      );
    }

    <span class="hljs-comment">// Read and process file</span>
    <span class="hljs-keyword">const</span> fileContent = <span class="hljs-keyword">await</span> <span class="hljs-title class_">Deno</span>.<span class="hljs-title function_">readTextFile</span>(filePath);
    <span class="hljs-keyword">const</span> lines = fileContent.<span class="hljs-title function_">trim</span>().<span class="hljs-title function_">split</span>(<span class="hljs-string">"\n"</span>).<span class="hljs-title function_">filter</span>(<span class="hljs-function"><span class="hljs-params">line</span> =&gt;</span> line.<span class="hljs-title function_">trim</span>().<span class="hljs-property">length</span> &gt; <span class="hljs-number">0</span>);

    <span class="hljs-keyword">let</span> processedCount = <span class="hljs-number">0</span>;
    <span class="hljs-keyword">let</span> errorCount = <span class="hljs-number">0</span>;
    <span class="hljs-keyword">const</span> <span class="hljs-attr">errors</span>: <span class="hljs-built_in">string</span>[] = [];
    <span class="hljs-keyword">const</span> <span class="hljs-attr">userCounts</span>: <span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">number</span>, <span class="hljs-built_in">number</span>&gt; = {};

    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> line <span class="hljs-keyword">of</span> lines) {
      <span class="hljs-keyword">try</span> {
        <span class="hljs-comment">// Parse format: {user_id} {json}</span>
        <span class="hljs-keyword">const</span> spaceIndex = line.<span class="hljs-title function_">indexOf</span>(<span class="hljs-string">" "</span>);
        <span class="hljs-keyword">if</span> (spaceIndex === -<span class="hljs-number">1</span>) {
          errorCount++;
          errors.<span class="hljs-title function_">push</span>(<span class="hljs-string">`Invalid line format (missing space): <span class="hljs-subst">${line.substring(<span class="hljs-number">0</span>, <span class="hljs-number">50</span>)}</span>`</span>);
          <span class="hljs-keyword">continue</span>;
        }

        <span class="hljs-keyword">const</span> userIdStr = line.<span class="hljs-title function_">substring</span>(<span class="hljs-number">0</span>, spaceIndex);
        <span class="hljs-keyword">const</span> userId = <span class="hljs-built_in">parseInt</span>(userIdStr, <span class="hljs-number">10</span>);
        
        <span class="hljs-keyword">if</span> (<span class="hljs-built_in">isNaN</span>(userId) || userId &lt;= <span class="hljs-number">0</span>) {
          errorCount++;
          errors.<span class="hljs-title function_">push</span>(<span class="hljs-string">`Invalid user_id: <span class="hljs-subst">${userIdStr}</span>`</span>);
          <span class="hljs-keyword">continue</span>;
        }

        <span class="hljs-keyword">const</span> jsonStr = line.<span class="hljs-title function_">substring</span>(spaceIndex + <span class="hljs-number">1</span>);
        <span class="hljs-keyword">const</span> jsonData = <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">parse</span>(jsonStr) <span class="hljs-keyword">as</span> <span class="hljs-title class_">ApiRequest</span>;
        
        <span class="hljs-keyword">if</span> (!jsonData.<span class="hljs-property">locations</span> || !<span class="hljs-title class_">Array</span>.<span class="hljs-title function_">isArray</span>(jsonData.<span class="hljs-property">locations</span>)) {
          errorCount++;
          errors.<span class="hljs-title function_">push</span>(<span class="hljs-string">`Invalid locations array in line`</span>);
          <span class="hljs-keyword">continue</span>;
        }

      <span class="hljs-comment">// Forward to upstream FIRST (same behavior as live ingest)</span>
      <span class="hljs-keyword">try</span> {
        <span class="hljs-keyword">const</span> upstreamResp = <span class="hljs-keyword">await</span> <span class="hljs-title function_">forwardReprocessRequest</span>(jsonData, userId);
        <span class="hljs-keyword">if</span> (!upstreamResp.<span class="hljs-property">ok</span>) {
          <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">`Upstream failed: <span class="hljs-subst">${upstreamResp.status}</span>`</span>);
        }
      } <span class="hljs-keyword">catch</span> (error) {
        errorCount++;
        errors.<span class="hljs-title function_">push</span>(<span class="hljs-string">`Upstream error for user <span class="hljs-subst">${userId}</span>: <span class="hljs-subst">${<span class="hljs-built_in">String</span>(error)}</span>`</span>);
        <span class="hljs-keyword">continue</span>; <span class="hljs-comment">// skip DB insert if upstream fails</span>
      }

      <span class="hljs-comment">// Then write to database</span>
      <span class="hljs-keyword">await</span> <span class="hljs-title function_">insertLocations</span>(jsonData.<span class="hljs-property">locations</span>, userId);

      processedCount++;
      userCounts[userId] = (userCounts[userId] || <span class="hljs-number">0</span>) + <span class="hljs-number">1</span>;

      } <span class="hljs-keyword">catch</span> (error) {
        errorCount++;
        errors.<span class="hljs-title function_">push</span>(<span class="hljs-string">`Error processing line: <span class="hljs-subst">${<span class="hljs-built_in">String</span>(error)}</span>`</span>);
        <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">error</span>(<span class="hljs-string">"Error processing line:"</span>, error);
      }
    }

    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
      <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({
        <span class="hljs-attr">success</span>: <span class="hljs-literal">true</span>,
        <span class="hljs-attr">message</span>: <span class="hljs-string">"Reprocessing completed"</span>,
        <span class="hljs-attr">date</span>: dateStr,
        <span class="hljs-attr">processed</span>: processedCount,
        <span class="hljs-attr">errors</span>: errorCount,
        <span class="hljs-attr">user_counts</span>: userCounts,
        <span class="hljs-attr">error_details</span>: errors.<span class="hljs-property">length</span> &gt; <span class="hljs-number">0</span> ? errors.<span class="hljs-title function_">slice</span>(<span class="hljs-number">0</span>, <span class="hljs-number">10</span>) : <span class="hljs-literal">undefined</span>, <span class="hljs-comment">// Limit error details</span>
      }),
      { <span class="hljs-attr">status</span>: <span class="hljs-number">200</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
    );
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">error</span>(<span class="hljs-string">"Reprocess error:"</span>, error);
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
      <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">"Internal server error"</span>, <span class="hljs-attr">details</span>: <span class="hljs-title class_">String</span>(error) }),
      { <span class="hljs-attr">status</span>: <span class="hljs-number">500</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
    );
  }
}

<span class="hljs-comment">// Main server handler</span>
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">handler</span>(<span class="hljs-params"><span class="hljs-attr">request</span>: <span class="hljs-title class_">Request</span></span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">Response</span>&gt; {
  <span class="hljs-keyword">const</span> url = <span class="hljs-keyword">new</span> <span class="hljs-title function_">URL</span>(request.<span class="hljs-property">url</span>);

  <span class="hljs-keyword">if</span> (url.<span class="hljs-property">pathname</span> === <span class="hljs-string">"/api/v1/ingest/overland"</span> &amp;&amp; request.<span class="hljs-property">method</span> === <span class="hljs-string">"POST"</span>) {
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> <span class="hljs-title function_">handleApiRequest</span>(request);
  }

  <span class="hljs-comment">// Handle reprocess endpoint: /api/v1/reprocess/YYYY-MM-DD</span>
  <span class="hljs-keyword">if</span> (url.<span class="hljs-property">pathname</span>.<span class="hljs-title function_">startsWith</span>(<span class="hljs-string">"/api/v1/reprocess/"</span>) &amp;&amp; request.<span class="hljs-property">method</span> === <span class="hljs-string">"POST"</span>) {
    <span class="hljs-keyword">const</span> dateStr = url.<span class="hljs-property">pathname</span>.<span class="hljs-title function_">replace</span>(<span class="hljs-string">"/api/v1/reprocess/"</span>, <span class="hljs-string">""</span>);
    <span class="hljs-keyword">if</span> (dateStr &amp;&amp; <span class="hljs-regexp">/^\d{4}-\d{2}-\d{2}$/</span>.<span class="hljs-title function_">test</span>(dateStr)) {
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> <span class="hljs-title function_">handleReprocessRequest</span>(request, dateStr);
    } <span class="hljs-keyword">else</span> {
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
        <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">"Invalid date format in URL. Expected /api/v1/reprocess/YYYY-MM-DD"</span> }),
        { <span class="hljs-attr">status</span>: <span class="hljs-number">400</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
      );
    }
  }

  <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(<span class="hljs-string">"Not Found"</span>, { <span class="hljs-attr">status</span>: <span class="hljs-number">404</span> });
}

<span class="hljs-comment">// Start server</span>
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">main</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">await</span> <span class="hljs-title function_">initDatabase</span>();

  <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-string">`Server listening on port <span class="hljs-subst">${CONFIG.port}</span>`</span>);
  <span class="hljs-keyword">await</span> <span class="hljs-title function_">serve</span>(handler, { <span class="hljs-attr">hostname</span>: <span class="hljs-string">'0.0.0.0'</span>, <span class="hljs-attr">port</span>: <span class="hljs-variable constant_">CONFIG</span>.<span class="hljs-property">port</span> });
}

<span class="hljs-comment">// Handle cleanup</span>
<span class="hljs-title class_">Deno</span>.<span class="hljs-title function_">addSignalListener</span>(<span class="hljs-string">"SIGINT"</span>, <span class="hljs-title function_">async</span> () =&gt; {
  <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-string">"\nShutting down..."</span>);
  <span class="hljs-keyword">if</span> (dbClient) {
    <span class="hljs-keyword">await</span> dbClient.<span class="hljs-title function_">end</span>();
  }
  <span class="hljs-title class_">Deno</span>.<span class="hljs-title function_">exit</span>(<span class="hljs-number">0</span>);
});

<span class="hljs-keyword">if</span> (<span class="hljs-keyword">import</span>.<span class="hljs-property">meta</span>.<span class="hljs-property">main</span>) {
  <span class="hljs-title function_">main</span>();
}
</code></pre>
<pre data-language="sql"><code class="language-sql"><span class="hljs-keyword">CREATE</span> TYPE public.battery_state_type <span class="hljs-keyword">AS</span> ENUM
    (<span class="hljs-string">'unknown'</span>, <span class="hljs-string">'charging'</span>, <span class="hljs-string">'full'</span>, <span class="hljs-string">'unplugged'</span>);

<span class="hljs-keyword">CREATE</span> TYPE public.motion_type <span class="hljs-keyword">AS</span> ENUM
    (<span class="hljs-string">'driving'</span>, <span class="hljs-string">'walking'</span>, <span class="hljs-string">'running'</span>, <span class="hljs-string">'cycling'</span>, <span class="hljs-string">'stationary'</span>, <span class="hljs-string">'automotive_navigation'</span>, <span class="hljs-string">'fitness'</span>, <span class="hljs-string">'other_navigation'</span>, <span class="hljs-string">'other'</span>, <span class="hljs-string">'moving'</span>, <span class="hljs-string">'uncertain'</span>);

<span class="hljs-keyword">CREATE TABLE</span> IF <span class="hljs-keyword">NOT</span> <span class="hljs-keyword">EXISTS</span> public.positions
(
    user_id <span class="hljs-type">integer</span> <span class="hljs-keyword">NOT NULL</span>,
    ts <span class="hljs-type">timestamp</span> <span class="hljs-keyword">without</span> <span class="hljs-type">time</span> zone <span class="hljs-keyword">NOT NULL</span>,
    geom geometry(Point,<span class="hljs-number">4326</span>) <span class="hljs-keyword">NOT NULL</span>,
    horizontal_accuracy <span class="hljs-type">numeric</span>(<span class="hljs-number">6</span>,<span class="hljs-number">2</span>),
    altitude <span class="hljs-type">numeric</span>(<span class="hljs-number">7</span>,<span class="hljs-number">2</span>),
    vertical_accuracy <span class="hljs-type">numeric</span>(<span class="hljs-number">5</span>,<span class="hljs-number">2</span>),
    course <span class="hljs-type">numeric</span>(<span class="hljs-number">4</span>,<span class="hljs-number">1</span>),
    course_accuracy <span class="hljs-type">numeric</span>(<span class="hljs-number">4</span>,<span class="hljs-number">1</span>),
    speed <span class="hljs-type">numeric</span>(<span class="hljs-number">5</span>,<span class="hljs-number">2</span>),
    speed_accuracy <span class="hljs-type">numeric</span>(<span class="hljs-number">4</span>,<span class="hljs-number">1</span>),
    battery_state battery_state_type,
    battery_level <span class="hljs-type">numeric</span>(<span class="hljs-number">3</span>,<span class="hljs-number">2</span>),
    motions motion_type[],
    wifi text <span class="hljs-keyword">COLLATE</span> pg_catalog."default",
    <span class="hljs-keyword">CONSTRAINT</span> positions_pkey <span class="hljs-keyword">PRIMARY KEY</span> (ts, user_id, geom)
);
</code></pre>
]]></content>
        <author>
            <name>Yingyu Cheng</name>
            <email>emerald_cahoots0j@icloud.com</email>
            <uri>https://github.com/winguse</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[Setting Up a Podman Machine Bridge Network on macOS: A Step-by-Step Guide]]></title>
        <id>https://wingu.se/2024/10/16/podman-compose-bridge-network/en.html</id>
        <link href="https://wingu.se/2024/10/16/podman-compose-bridge-network/en.html"/>
        <updated>2024-10-17T02:30:00.000Z</updated>
        <summary type="html"><![CDATA[
Running containers on macOS using Podman involves setting up a Linux virtual machine (VM) to handle...]]></summary>
        <content type="html"><![CDATA[<p><img src="https://winguse.com/view-counter?r=wingu.se/2024/10/16/podman-compose-bridge-network/en&from=feed" style="vertical-align: middle; height: 1em;"/></p>
<p>Running containers on macOS using Podman involves setting up a Linux virtual machine (VM) to handle the containers. However, when using a Docker Compose configuration with a <code>bridge</code> network, you may encounter challenges accessing the containers directly from macOS. This blog will guide you through how to set up a bridge network between your Podman VM and macOS using WireGuard, enabling direct access to your containers.</p>
<h2 id="problem-overview" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network/en.html#problem-overview">Problem Overview</a></h2>
<p>Let’s say you have a Docker Compose configuration like the one below, which defines a Redis leader and replica on a custom network:</p>
<pre data-language="yaml"><code class="language-yaml"><span class="hljs-attr">services:</span>
  <span class="hljs-attr">redis-leader:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">redis:6.2.6-alpine</span>
    <span class="hljs-attr">networks:</span>
      <span class="hljs-attr">my-net:</span>
        <span class="hljs-attr">ipv4_address:</span> <span class="hljs-number">10.2</span><span class="hljs-number">.2</span><span class="hljs-number">.100</span>

  <span class="hljs-attr">redis-replica:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">redis:6.2.6-alpine</span>
    <span class="hljs-attr">command:</span> <span class="hljs-string">redis-server</span> <span class="hljs-string">--replicaof</span> <span class="hljs-string">redis-leader</span> <span class="hljs-number">6379</span>
    <span class="hljs-attr">depends_on:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">redis-leader</span>
    <span class="hljs-attr">networks:</span>
      <span class="hljs-attr">my-net:</span>
        <span class="hljs-attr">ipv4_address:</span> <span class="hljs-number">10.2</span><span class="hljs-number">.2</span><span class="hljs-number">.101</span>

<span class="hljs-attr">networks:</span>
  <span class="hljs-attr">my-net:</span>
    <span class="hljs-attr">driver:</span> <span class="hljs-string">bridge</span>
    <span class="hljs-attr">ipam:</span>
      <span class="hljs-attr">config:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">subnet:</span> <span class="hljs-number">10.2</span><span class="hljs-number">.2</span><span class="hljs-number">.0</span><span class="hljs-string">/24</span>
</code></pre>
<p>In this setup, you won’t be able to directly access the Redis containers from macOS because the bridge network only connects the containers inside the Podman VM, isolating them from the macOS host.</p>
<h3 id="solution-use-wire-guard-to-bridge-networks" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network/en.html#solution-use-wire-guard-to-bridge-networks">Solution: Use WireGuard to Bridge Networks</a></h3>
<p>To solve this problem, we will set up a WireGuard connection between the Podman VM and macOS. This setup allows macOS to communicate with containers running on the VM.</p>
<h2 id="step-1-install-wire-guard-on-mac-os" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network/en.html#step-1-install-wire-guard-on-mac-os">Step 1: Install WireGuard on macOS</a></h2>
<p>Start by installing the WireGuard tools on your macOS system using Homebrew:</p>
<pre data-language="bash"><code class="language-bash">brew install wireguard-tools
</code></pre>
<h2 id="step-2-generate-keys-for-wire-guard" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network/en.html#step-2-generate-keys-for-wire-guard">Step 2: Generate Keys for WireGuard</a></h2>
<p>Next, you need to generate two pairs of public and private keys—one for the Podman VM and one for macOS. Run the following command twice to generate the keys:</p>
<pre data-language="bash"><code class="language-bash">wg genkey | <span class="hljs-built_in">tee</span> /dev/stderr | wg pubkey
</code></pre>
<p>The first line of the output will be the private key, and the second line will be the public key. Make sure to run this command twice to generate two sets of keys.</p>
<h2 id="step-3-configure-wire-guard-on-mac-os" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network/en.html#step-3-configure-wire-guard-on-mac-os">Step 3: Configure WireGuard on macOS</a></h2>
<p>After generating the keys, configure WireGuard on macOS. First, create the necessary directory:</p>
<pre data-language="bash"><code class="language-bash"><span class="hljs-built_in">sudo</span> <span class="hljs-built_in">mkdir</span> -p /opt/homebrew/etc/wireguard/
</code></pre>
<p>Then, create the configuration file <code>/opt/homebrew/etc/wireguard/wg0.conf</code>:</p>
<pre data-language="bash"><code class="language-bash"><span class="hljs-built_in">cat</span> &lt;&lt;<span class="hljs-string">EOF &gt; /opt/homebrew/etc/wireguard/wg0.conf
[Interface]
PrivateKey = &lt;private key A&gt; # private key for macOS
Address = 10.0.0.2/24 # WireGuard IP for macOS
ListenPort = 51820 # listening port for WireGuard
PostUp = ifconfig lo0 inet 100.64.64.64/30 100.64.64.64 alias
PostDown = ifconfig lo0 inet 100.64.64.64/30 100.64.64.64 delete

[Peer]
PublicKey = &lt;public key B&gt; # public key for Podman VM
AllowedIPs = 10.2.0.0/16, 10.0.0.1/32 # range of the bridge network
PersistentKeepalive = 25
EOF</span>
</code></pre>
<p>The <code>AllowedIPs</code> field should match your Docker bridge network range. Start WireGuard on macOS using the following command:</p>
<pre data-language="bash"><code class="language-bash"><span class="hljs-built_in">sudo</span> wg-quick up wg0
</code></pre>
<p>Check the status of WireGuard by running:</p>
<pre data-language="bash"><code class="language-bash"><span class="hljs-built_in">sudo</span> wg
</code></pre>
<p>Keep in mind that after a reboot, you’ll need to run <code>sudo wg-quick up wg0</code> again to restart WireGuard.</p>
<h2 id="step-4-set-up-wire-guard-on-the-podman-vm" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network/en.html#step-4-set-up-wire-guard-on-the-podman-vm">Step 4: Set Up WireGuard on the Podman VM</a></h2>
<p>Next, log in to the Podman VM via SSH:</p>
<pre data-language="bash"><code class="language-bash">podman machine ssh
</code></pre>
<p>Create a WireGuard configuration file <code>/etc/wireguard/wg0.conf</code>:</p>
<pre data-language="bash"><code class="language-bash"><span class="hljs-built_in">cat</span> &lt;&lt; <span class="hljs-string">EOF &gt; /etc/wireguard/wg0.conf
[Interface]
PrivateKey = &lt;private key B&gt; # private key for the Podman VM
Address = 10.0.0.1/24 # WireGuard IP for Podman VM
PostUp = iptables -A FORWARD -i %i -j ACCEPT
PostDown = iptables -D FORWARD -i %i -j ACCEPT

[Peer]
PublicKey = &lt;public key A&gt; # public key for macOS
AllowedIPs = 10.0.0.2/32 # WireGuard IP for macOS
Endpoint = 100.64.64.64:51820
PersistentKeepalive = 25
EOF</span>
</code></pre>
<p>Start WireGuard on the Podman VM with:</p>
<pre data-language="bash"><code class="language-bash">wg-quick up wg0
</code></pre>
<p>To ensure that WireGuard starts automatically whenever the Podman machine starts, enable it with:</p>
<pre data-language="bash"><code class="language-bash">systemctl <span class="hljs-built_in">enable</span> wg-quick@wg0
</code></pre>
<h2 id="step-5-access-the-containers-from-mac-os" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network/en.html#step-5-access-the-containers-from-mac-os">Step 5: Access the Containers from macOS</a></h2>
<p>Once WireGuard is running on both macOS and the Podman VM, you should be able to access the containers directly from macOS. For example, to ping the Redis replica:</p>
<pre data-language="bash"><code class="language-bash">ping 10.2.2.101
</code></pre>
<p>You should receive a response like:</p>
<pre data-language="bash"><code class="language-bash">PING 10.2.2.101 (10.2.2.101): 56 data bytes
64 bytes from 10.2.2.101: icmp_seq=0 ttl=63 <span class="hljs-keyword">time</span>=5.992 ms
</code></pre>
<h2 id="conclusion" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network/en.html#conclusion">Conclusion</a></h2>
<p>By configuring a WireGuard connection between your Podman VM and macOS, you can successfully bridge the network and access containers directly from your macOS host. This setup is particularly useful when working with isolated containers in a <code>bridge</code> network on macOS using Podman.</p>
]]></content>
        <author>
            <name>Yingyu Cheng</name>
            <email>emerald_cahoots0j@icloud.com</email>
            <uri>https://github.com/winguse</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[Apple allows applications to track user locations without authorization]]></title>
        <id>https://wingu.se/2023/11/30/only-apple-can-do-allow-apps-tracking-users-location-without-consensus/en.html</id>
        <link href="https://wingu.se/2023/11/30/only-apple-can-do-allow-apps-tracking-users-location-without-consensus/en.html"/>
        <updated>2023-11-30T16:30:00.000Z</updated>
        <summary type="html"><![CDATA[
Apple asserts itself as a champion of user privacy; however, this claim will be proven untrue in th...]]></summary>
        <content type="html"><![CDATA[<p><img src="https://winguse.com/view-counter?r=wingu.se/2023/11/30/only-apple-can-do-allow-apps-tracking-users-location-without-consensus/en&from=feed" style="vertical-align: middle; height: 1em;"/></p>
<p>Apple asserts itself as a champion of user privacy; however, this claim will be proven untrue in this article.
For almost a decade, Apple allowed apps had the capability to track users' locations without affording them the option to disable this feature or even raising awareness about it.
And this is "ONLY APPLE CAN DO"!</p>
<h2 id="the-hotspot-helper-api-in-action" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2023/11/30/only-apple-can-do-allow-apps-tracking-users-location-without-consensus/en.html#the-hotspot-helper-api-in-action">The HotspotHelper API in Action</a></h2>
<p>Since the introduction of iOS 9 in 2015, Apple has included an API call named "HotspotHelper," enabling developers to request a capability for their apps to assist the system in connecting to WiFi access points.
Let's delve into how this API works with a simplified code snippet:</p>
<pre data-language="swift"><code class="language-swift"><span class="hljs-keyword">import</span> CoreLocation
<span class="hljs-keyword">import</span> NetworkExtension

<span class="hljs-keyword">class</span> <span class="hljs-title class_">LocationTrackingManager</span> {
    <span class="hljs-keyword">func</span> <span class="hljs-title function_">setupHotspotHelper</span>() {
        <span class="hljs-comment">// Request HotspotHelper capability</span>
        <span class="hljs-type">NEHotspotHelper</span>.register(options: <span class="hljs-literal">nil</span>, queue: <span class="hljs-type">DispatchQueue</span>.main) { (command) <span class="hljs-keyword">in</span>
            <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> networkList <span class="hljs-operator">=</span> command.networkList {
                <span class="hljs-keyword">for</span> network <span class="hljs-keyword">in</span> networkList {
                    <span class="hljs-comment">// Access WiFi network information (SSID, MAC address)</span>
                    <span class="hljs-comment">// see: https://developer.apple.com/documentation/networkextension/nehotspotnetwork</span>
                    <span class="hljs-keyword">let</span> ssid <span class="hljs-operator">=</span> network.ssid
                    <span class="hljs-keyword">let</span> macAddress <span class="hljs-operator">=</span> network.bssid

                    <span class="hljs-comment">// Perform location tracking logic with ssid and macAddress</span>
                    <span class="hljs-keyword">self</span>.trackLocation(withSSID: ssid, andMACAddress: macAddress)
                }
            }
        }
    }

    <span class="hljs-keyword">func</span> <span class="hljs-title function_">trackLocation</span>(<span class="hljs-params">withSSID</span> <span class="hljs-params">ssid</span>: <span class="hljs-type">String</span>, <span class="hljs-params">andMACAddress</span> <span class="hljs-params">macAddress</span>: <span class="hljs-type">String</span>) {
        <span class="hljs-comment">// Your location tracking logic goes here</span>
        <span class="hljs-comment">// Use the ssid and macAddress to determine user location</span>
    }
}
</code></pre>
<p>This snippet demonstrates how developers can utilize the HotspotHelper API to register for WiFi network information.
The trackLocation method showcases the potential for extracting data that can be used for location tracking.</p>
<h2 id="the-privacy-dilemma" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2023/11/30/only-apple-can-do-allow-apps-tracking-users-location-without-consensus/en.html#the-privacy-dilemma">The Privacy Dilemma</a></h2>
<p>The real cause for concern arises from the fact that, with access to such information, apps can effectively track a user's location.
This is based on the premise that most WiFi access points remain stationary after deployment, providing a consistent reference for triangulating a user's whereabouts.
Public API avalible such as <a href="https://developer.precisely.com/apis/geolocation" target="_blank" rel="noopener noreferrer">Precisely Location By Wi-fi Access Point</a>,
<a href="https://developers.google.com/maps/documentation/geolocation/requests-geolocation" target="_blank" rel="noopener noreferrer">Google's Geolocation API</a>.
While the intentions behind HotspotHelper may be rooted in facilitating seamless connectivity, the unintended consequence of potential location tracking without explicit user consent raises eyebrows in the ongoing privacy debate.</p>
<p>This capability is activated whenever the user's device scans nearby WiFi access points, extending beyond explicit user engagement with the system settings to include instances where the device is locked in someone's pocket.
The system will initiate the registered app with this API, enabling the app to retrieve nearby SSIDs and their MAC addresses and transmit this information to the server side.
Consequently, if the app developer wishes, they possess the capability to nearly real-time track the user's location.
Importantly, users remain unaware of this process occurring on their screens, and they lack the option to disable it.
On the other hand, almost all the users doesn't know the App has this feature and they don't need/use this feature to help their lives.
But again, they have no choice, their devices has to launch the App and submit near by WiFi info to the developers of the App.</p>
<h2 id="global-impact-we-chat-and-alipay" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2023/11/30/only-apple-can-do-allow-apps-tracking-users-location-without-consensus/en.html#global-impact-we-chat-and-alipay">Global Impact: WeChat and Alipay</a></h2>
<p>Adding another layer to the discussion is the fact that major apps like WeChat and Alipay have already implemented this capability.
These two apps are ubiquitous in mainland China, touching almost every aspect of people's lives.
The widespread use of these applications in a densely populated region intensifies the implications of location tracking without user consent.</p>
<p>A compelling debate could center around whether WeChat and/or Alipay function as responsible citizens in the app world,
asserting that their data collection aims solely at enhancing user experience and facilitating seamless connections to nearby WiFi.
Nevertheless, the opaque server-side logic embedded in their code raises questions.
Could it be that once again, "ONLY APPLE CAN DO" in terms of ensuring transparency and accountability?</p>
<h2 id="apples-response" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2023/11/30/only-apple-can-do-allow-apps-tracking-users-location-without-consensus/en.html#apples-response">Apple's "response"</a></h2>
<p>In reality, I discovered this issue approximately two years ago and created a <a href="https://www.bilibili.com/video/BV16Z4y1Q7fN/" target="_blank" rel="noopener noreferrer">video</a> on Bilibili (a Chinese alternative to YouTube) discussing the matter.
However, it has only very limited public awareness. I also brought this concern to Apple's attention and received an email response, but as of now, there has been no further update on the matter.</p>
<p><img src="https://wingu.se/_file/images/apple-response-to-hotspot-helper.3579bd72.jpg" alt="Apple email response regarding HotspotHelper"></p>
<h2 id="conclusions" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2023/11/30/only-apple-can-do-allow-apps-tracking-users-location-without-consensus/en.html#conclusions">Conclusions</a></h2>
<p>I strongly advocate for Apple to offer users the option to disable this feature, akin to other privacy settings such as location and notifications.
Apps should explicitly seek permission before accessing this feature, ensuring users have the ability to grant or deny access while using the app.</p>
<p>As the conversation around digital privacy continues to evolve, Apple finds itself navigating the fine line between innovation and safeguarding user data.
The question remains: can Apple maintain its commitment to privacy while addressing concerns raised by the HotspotHelper feature?
Only time will tell how this controversial aspect fits into Apple's broader privacy narrative.</p>
<blockquote>
<p>Credit: This article was written with the assistance of ChatGPT for the purpose of refining my English writing.</p>
</blockquote>
]]></content>
        <author>
            <name>Yingyu Cheng</name>
            <email>emerald_cahoots0j@icloud.com</email>
            <uri>https://github.com/winguse</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[Import GPG Public Key from YubiKey]]></title>
        <id>https://wingu.se/2023/11/18/import-gpg-key-from-yubikey/en.html</id>
        <link href="https://wingu.se/2023/11/18/import-gpg-key-from-yubikey/en.html"/>
        <updated>2023-11-18T20:30:00.000Z</updated>
        <summary type="html"><![CDATA[
This post mainly references this blog post and this guide. Here is a brief summary.
# reset all gpg...]]></summary>
        <content type="html"><![CDATA[<p><img src="https://winguse.com/view-counter?r=wingu.se/2023/11/18/import-gpg-key-from-yubikey/en&from=feed" style="vertical-align: middle; height: 1em;"/></p>
<p>This post mainly references <a href="https://www.nicksherlock.com/2021/08/recovering-lost-gpg-public-keys-from-your-yubikey/" target="_blank" rel="noopener noreferrer">this blog post</a> and <a href="https://github.com/drduh/YubiKey-Guide/tree/master" target="_blank" rel="noopener noreferrer">this guide</a>. Here is a brief summary.</p>
<pre data-language="shell"><code class="language-shell"><span class="hljs-meta prompt_"># </span><span class="language-bash">reset all gpg data</span>
rm -r ~/.gnupg
<span class="hljs-meta prompt_">
# </span><span class="language-bash">list key on YubiKey</span>
gpg --card-status --with-keygrip
<span class="hljs-meta prompt_">
# </span><span class="language-bash">get the <span class="hljs-built_in">date</span> <span class="hljs-keyword">time</span> of the key above and generate pub key with the <span class="hljs-built_in">date</span> above</span>
gpg --faked-system-time '20231112T191616!' --full-generate-key
<span class="hljs-meta prompt_">
# </span><span class="language-bash">import the subkeys, use `addkey` <span class="hljs-keyword">in</span> the prompt</span>
gpg --faked-system-time '20231112T191616!' --edit-key A83F5C04715B2C25DB2FBEA7DBBF1C31DD587CC6
<span class="hljs-meta prompt_">
# </span><span class="language-bash"><span class="hljs-built_in">export</span> key</span>
gpg --armor --export A83F5C04715B2C25DB2FBEA7DBBF1C31DD587CC6
<span class="hljs-meta prompt_">
# </span><span class="language-bash">import key</span>
gpg --import keys/*
<span class="hljs-meta prompt_">
# </span><span class="language-bash">trust key, use the trust <span class="hljs-built_in">command</span> <span class="hljs-keyword">in</span> the prompt</span>
gpg --edit-key A83F5C04715B2C25DB2FBEA7DBBF1C31DD587CC6
<span class="hljs-meta prompt_">
# </span><span class="language-bash">admin the yubikey</span>
gpg --card-edit
<span class="hljs-meta prompt_">
# </span><span class="language-bash">encrypt</span>
gpg --encrypt \
  --recipient BE387B4AEF2E85A025C0EAF8A603F43145D6FC6D \
  --recipient A83F5C04715B2C25DB2FBEA7DBBF1C31DD587CC6 \
  --output output.gpg \
  input_file.txt
<span class="hljs-meta prompt_">
# </span><span class="language-bash">list the encrypted file</span>
gpg --pinentry-mode cancel --list-packets file.gpg
</code></pre>
]]></content>
        <author>
            <name>Yingyu Cheng</name>
            <email>emerald_cahoots0j@icloud.com</email>
            <uri>https://github.com/winguse</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[DNAT Preserving Client IP with Symmetric Routing]]></title>
        <id>https://wingu.se/2023/04/21/dnat-source-in-source-out/en.html</id>
        <link href="https://wingu.se/2023/04/21/dnat-source-in-source-out/en.html"/>
        <updated>2023-04-21T21:30:00.000Z</updated>
        <summary type="html"><![CDATA[
Recently I got a symmetric broadband connection, so I wanted to move some services back home... Aft...]]></summary>
        <content type="html"><![CDATA[<p><img src="https://winguse.com/view-counter?r=wingu.se/2023/04/21/dnat-source-in-source-out/en&from=feed" style="vertical-align: middle; height: 1em;"/></p>
<p>Recently I got a symmetric broadband connection, so I wanted to move some services back home... After all, for things like trusted computing, it's still better to run them on your own hardware.</p>
<p>If you only do simple port forwarding, the server at home cannot see the client's real address, so I wanted to do something about that. This situation shouldn't be that uncommon, and I had tinkered with iptables before. In fact, I had written the server-side DNAT rules a long time ago, but for the life of me I just couldn't get it working.</p>
<pre data-language="bash"><code class="language-bash"><span class="hljs-meta">#!/bin/sh</span>

sysctl -w net.ipv4.ip_forward=1
iptables -P FORWARD DROP
iptables -F FORWARD
iptables -t nat -F

wg-quick down wg_px
wg-quick up wg_px

pub_addr=1.2.3.4
prv_addr=192.168.101.2
pub_if=eth0
prv_if=wg_px
proto=tcp


<span class="hljs-function"><span class="hljs-title">port_map</span></span>() {
  bind_port=<span class="hljs-variable">$1</span>
  prv_port=<span class="hljs-variable">$2</span>

  iptables -t nat -A PREROUTING -p <span class="hljs-variable">$proto</span> -d <span class="hljs-variable">$pub_addr</span> --dport <span class="hljs-variable">$bind_port</span> -j DNAT --to <span class="hljs-variable">$prv_addr</span>:<span class="hljs-variable">$prv_port</span>
  iptables -I FORWARD -p <span class="hljs-variable">$proto</span> -i <span class="hljs-variable">$pub_if</span> -o <span class="hljs-variable">$prv_if</span> -d <span class="hljs-variable">$prv_addr</span> --dport <span class="hljs-variable">$prv_port</span> -j ACCEPT
  iptables -t nat -A POSTROUTING -p <span class="hljs-variable">$proto</span> -s <span class="hljs-variable">$prv_addr</span> --sport <span class="hljs-variable">$prv_port</span> -j SNAT --to <span class="hljs-variable">$pub_addr</span>:<span class="hljs-variable">$bind_port</span>
}

iptables -I FORWARD -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT
iptables -I FORWARD -p tcp -m tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu

port_map 443 40443
port_map 80  40080

</code></pre>
<p>After writing the above, I discovered something very strange: for some reason, the return packets were being sent back onto WireGuard again. I couldn't figure it out, and I couldn't find any answer online either... In the end I thought, could this be a bug? Then I switched to another machine and found that it worked fine there... Cost me two hours...</p>
<p>Below is the script I used to ensure local packets are routed correctly:</p>
<pre data-language="bash"><code class="language-bash"><span class="hljs-meta">#!/bin/sh</span>

docker_if=br-web-services

<span class="hljs-function"><span class="hljs-title">ensure_chain</span></span>() {
  name=<span class="hljs-variable">$1</span>
  sys_chain=<span class="hljs-variable">$2</span>
  new_chain=$1_<span class="hljs-variable">$2</span>
  (iptables -t mangle -L | grep -qF -- <span class="hljs-string">"Chain <span class="hljs-variable">$new_chain</span>"</span>) || \
    (iptables -t mangle -N <span class="hljs-variable">$new_chain</span> &amp;&amp; iptables -t mangle -I <span class="hljs-variable">$sys_chain</span> -j <span class="hljs-variable">$new_chain</span>)
  iptables -t mangle -F <span class="hljs-variable">$new_chain</span>
}

ensure_chain WG_PX PREROUTING
<span class="hljs-comment"># ensure_chain WG_PX OUTPUT</span>


<span class="hljs-function"><span class="hljs-title">ensure_line</span></span>() {
  file=<span class="hljs-variable">$1</span>
  line=<span class="hljs-string">"<span class="hljs-variable">$2</span>"</span>
  grep -qF -- <span class="hljs-string">"<span class="hljs-variable">$line</span>"</span> <span class="hljs-variable">$file</span> || <span class="hljs-built_in">echo</span> <span class="hljs-variable">$line</span> &gt;&gt; <span class="hljs-variable">$file</span>
}

<span class="hljs-function"><span class="hljs-title">same_in_out</span></span>() {
  fw_if=<span class="hljs-variable">$1</span>
  fw_table=<span class="hljs-variable">$1_table</span>
  mk_value=<span class="hljs-variable">$2</span>

  <span class="hljs-comment"># wireguard</span>
  wg-quick down <span class="hljs-variable">$fw_if</span>
  wg-quick up <span class="hljs-variable">$fw_if</span>

  <span class="hljs-comment"># route</span>
  ensure_line /etc/iproute2/rt_tables <span class="hljs-string">"<span class="hljs-variable">$mk_value</span> <span class="hljs-variable">$fw_table</span>"</span>
  ip route flush table <span class="hljs-variable">$fw_table</span>
  ip route add default dev <span class="hljs-variable">$fw_if</span> table <span class="hljs-variable">$fw_table</span>
  existing_rule_count=$(ip rule list fwmark <span class="hljs-variable">$mk_value</span> | <span class="hljs-built_in">wc</span> -l)
  <span class="hljs-keyword">for</span> i <span class="hljs-keyword">in</span> $(<span class="hljs-built_in">seq</span> 1 <span class="hljs-variable">$existing_rule_count</span>)
  <span class="hljs-keyword">do</span>
    ip rule delete fwmark <span class="hljs-variable">$mk_value</span>
  <span class="hljs-keyword">done</span>
  ip rule add fwmark <span class="hljs-variable">$mk_value</span> table <span class="hljs-variable">$fw_table</span>

  <span class="hljs-comment"># iptable markers</span>
  iptables -t mangle -I WG_PX_PREROUTING -i <span class="hljs-variable">$fw_if</span> -j CONNMARK --set-mark <span class="hljs-variable">$mk_value</span>
  <span class="hljs-comment"># OUTPUT only for host itself, but it's using docker here</span>
  <span class="hljs-comment"># iptables -t mangle -I WG_PX_OUTPUT     -m connmark --mark $mk_value -j CONNMARK --restore-mark</span>
  iptables -t mangle -I WG_PX_PREROUTING -i <span class="hljs-variable">$docker_if</span> -m connmark --mark <span class="hljs-variable">$mk_value</span> -j CONNMARK --restore-mark
}


same_in_out wg_vps    101

</code></pre>
]]></content>
        <author>
            <name>Yingyu Cheng</name>
            <email>emerald_cahoots0j@icloud.com</email>
            <uri>https://github.com/winguse</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[Misconceptions About Safe Internet Access]]></title>
        <id>https://wingu.se/2021/07/29/networking/en.html</id>
        <link href="https://wingu.se/2021/07/29/networking/en.html"/>
        <updated>2021-07-29T13:19:00.000Z</updated>
        <summary type="html"><![CDATA[
There are many good people in the world, but there are also bad people, and the internet is no exce...]]></summary>
        <content type="html"><![CDATA[<p><img src="https://winguse.com/view-counter?r=wingu.se/2021/07/29/networking/en&from=feed" style="vertical-align: middle; height: 1em;"/></p>
<p>There are many good people in the world, but there are also bad people, and the internet is no exception, so no matter where you are, there is a need for safe internet access. In mainland China, for reasons that cannot be described, this need is especially great.</p>
<p>With everyone using their own tricks, there are many ways to access the internet safely on the market, and the number of methods keeps growing.</p>
<p>At the beginning, and the easiest method for people to find, was using HTTP proxies. You could also find many free servers online. In the era before HTTPS was fully widespread, this basically meant handing over all your information to the proxy server, while communication between you and the proxy server was also in plaintext. Even today, when HTTPS is relatively widespread, an HTTP proxy still exposes the domain names you visit. Similar to this are plaintext SOCKS proxies.</p>
<p>About nine years ago, the famous Shadowsocks project was launched, ushering in the era of encrypted proxies. At first, the project was very successful, but because of certain issues with its use of cipher suites, Shadowsocks became easy to detect. And although the protocol did not have especially distinctive handshake features, it was like a van driving on the road with blacked-out windows: Shadowsocks traffic was easy to arouse suspicion and get blocked. As a result, various obfuscation methods also appeared for Shadowsocks. Similarly, there was the V2Ray project. Although it solved many of Shadowsocks' problems and was much more powerful, it was also excessively complex to configure. In recent years, the Trojan project also appeared, directly using TLS for camouflage, but it likewise failed to escape the problem of overly complex configuration.</p>
<p>On the VPN side, there are also many solutions on the market. A representative example is PPTP, but due to security issues, PPTP is now rarely seen. Another is L2TP/PSK-IPSec. This protocol still exists widely, but because IPSec handshakes have various issues or get blocked, it is not especially stable. In recent years, Wireguard has won over many people with its concise and elegant design, and its performance is excellent. But like other VPNs, Wireguard lives a hard life because UDP is a second-class citizen on China's internet. And Wireguard also has obvious packet characteristics, making it easy to identify. There are actually TLS-based VPN solutions too, namely Microsoft's SSTP, but this solution is relatively difficult to deploy. Natively, you need Windows Server, and there are not many open-source implementations either (SoftEther is one of them). It is also hard to hide the exposed port running the VPN service.</p>
<p>On the path of hiding the intent to use a proxy or VPN and avoiding ISP QoS, both proxies and VPNs ultimately point toward TLS, disguising themselves as normal website services. But neither side has an especially good implementation, so recently I spent some time thinking and wrote a lot of tools myself. As of today, I have found that writing these tools is actually not that hard, and some of the tools mentioned above, in my opinion, are too big and too comprehensive, making things overly complicated. To implement these tools, the core really does not require that much code or logic.</p>
<p>At the very beginning, what we used was a normal HTTP proxy, but in fact the connection to the proxy server can also be established over TLS, which hides the fact that a proxy is being used, so I wrote the <a href="https://github.com/winguse/go-shp" target="_blank" rel="noopener noreferrer">go-shp</a> project. There were already server-side implementations available—for example, Caddy 1.x—but there were not many clients that supported it. Operating systems didn't support it, and among browsers I only saw Chrome support it (Firefox might also support it), so I also hacked together a local forwarding proxy. And since Chrome supports it, I wrote a browser extension too, though I took too big a step: the function to automatically detect and use a proxy involved a lot of code, yet I still didn't write it very well.</p>
<p>However, HTTP proxies are inherently unable to forward traffic other than TCP. When I have that kind of need, I use Wireguard. Using native Wireguard directly is indeed too easy to recognize, so I hacked together <a href="https://github.com/winguse/udp-xor" target="_blank" rel="noopener noreferrer">udp-xor</a>. But plain XOR still has recognizable features, so while practicing Rust, I wrote the <a href="https://github.com/winguse/udp-prepend" target="_blank" rel="noopener noreferrer">udp-prepend</a> project. UDP is nice, but because of QoS, I wrote <a href="https://github.com/winguse/ws-udp" target="_blank" rel="noopener noreferrer">ws-udp</a> to stuff UDP traffic into WebSocket, which then conveniently allows the use of TLS camouflage. Of course, this inevitably introduces the TCP-over-TCP problem, but there aren't many good solutions. Still, Wireguard already has one layer of encryption, and adding TLS WebSocket on top really annoyed me, so I started missing the goodness of SSTP. But as mentioned earlier, there wasn't any especially good implementation on the market, so I wrote <a href="https://github.com/winguse/ws-tun" target="_blank" rel="noopener noreferrer">ws-tun</a> as well.</p>
<p>At one point I wanted to implement an SSTP server, but the protocol is still a bit complicated, and it's not easy to make it blend in with normal traffic. For this reason, I chose to directly build my own tun or tap VPN. Honestly, I did find some open-source WebSocket implementations, but one used a niche language, and I gave up on another because it didn't support TLS. Besides, they were indeed written a bit too complicatedly. Between tun and tap, I didn't think tap was necessary, and it also requires root privileges to run, so I decided writing a tun implementation would be enough. Choosing WebSocket to transport tun packets was natural; after studying the WebSocket protocol, I found that its overhead is not too large, and with it I didn't need to implement my own fragmentation logic. I also didn't write the tun handling myself—I basically copied Cloudflare's boring-tun project and modified it until it worked. The only really painful part was the Rust development process: getting it to compile was miserable, and converting it to async also cost me a lot of time. This project has been tested to run harmoniously on macOS and Linux. As for configuration, the server and client only need to agree on one WebSocket address, and nothing else needs to be configured. I put both the server and client into the same program, which actually was not an especially good choice, because I designed the server to sit behind something like nginx, so TLS encryption does not need to be configured there, yet this still makes the server binary rather large. The client has no choice and must include the packages needed for TLS.</p>
<p>But the mobile path for ws-tun has not been so easy, because I had never written mobile apps before. This week, though, I spent a little time writing <a href="https://github.com/winguse/ws-tun-android" target="_blank" rel="noopener noreferrer">ws-tun-android</a>, and I discovered that Android-side VPN development is actually very simple. Google provides a ToyVPN project, and after modifying it a bit, it worked. But when I was about to write the iOS version last night, I found it was not so easy. First, a developer account is mandatory, and then NetworkExtension seems to be only for enterprise users now? So I GG'd and took my leave.</p>
<p>Anyway, after some time, maybe I won't need to keep tinkering with this anymore. Let this article serve as a summary of many years of tinkering with networking—from using other people's services, to using other people's code to set up a service for myself, to finally writing code myself to implement one. Thanks, GFW, for teaching me a lot.</p>
]]></content>
        <author>
            <name>Yingyu Cheng</name>
            <email>emerald_cahoots0j@icloud.com</email>
            <uri>https://github.com/winguse</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[A Weekend in Datong]]></title>
        <id>https://wingu.se/2021/07/18/da-tong/en.html</id>
        <link href="https://wingu.se/2021/07/18/da-tong/en.html"/>
        <updated>2021-07-18T15:00:00.000Z</updated>
        <summary type="html"><![CDATA[
Setting off
Last weekend I had no idea where to go, but the boss at home wanted to go out and have ...]]></summary>
        <content type="html"><![CDATA[<p><img src="https://winguse.com/view-counter?r=wingu.se/2021/07/18/da-tong/en&from=feed" style="vertical-align: middle; height: 1em;"/></p>
<h2 id="setting-off" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2021/07/18/da-tong/en.html#setting-off">Setting off</a></h2>
<p>Last weekend I had no idea where to go, but the boss at home wanted to go out and have some fun. I looked around Beijing and it seemed there wasn't really anywhere to go. After searching for a while, I discovered that Datong was only 2 hours away by high-speed rail, which was a bit surprising, so I took the Beijing-Zhangjiakou high-speed rail for the first time.</p>
<p>The train starts from Qinghe Station and ends at Datong South. In fact, if you don't take one of the trains that stops at many stations, the faster ones can get there in just a little over 1 hour and 40 minutes.</p>
<p>The taxi driver taking us to Qinghe Station said the station had only been completed last year, and some road signs were still inaccurate. That reminded me of the last time I passed Qinghe, when Line 13 was still running on temporary tracks and the site of Qinghe Station was still a huge pit. Of course, the COVID pandemic seems to have made the clock spin faster than before. In the blink of an eye, more than two years have passed: the station is finished, and the Beijing-Zhangjiakou high-speed rail is open. This line, built for the Winter Olympics, even has little athlete figures on the guardrails. Qinghe Station is already outside the Fifth Ring Road, so the train doesn't run underground there. I wonder whether I'll get a chance next time to try taking an underground high-speed train from Beijing North.</p>
<p>The weather in North China was great on Friday. New train, new line—everything was very clean. Combined with the ridiculously high saturation of the scenery outside the window, it reminded me of a trip to Kansai in Japan a few years ago, when I accidentally passed my stop and saw the rural countryside scenery of Japan.</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/jing-zhang-gao-tie-1.2d05762e.jpeg" alt="On the Beijing-Zhangjiakou high-speed rail"></p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/jing-zhang-gao-tie-2.7c3042d4.jpeg" alt="On the Beijing-Zhangjiakou high-speed rail"></p>
<p>When we arrived in Datong, there was still a little evening glow in the sky. Coming out of Datong South Station, we took a taxi straight to the city center. My first impression was: this city is really new. Almost everything looked newly built.</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/da-tong-wan-xia.cdf7264d.jpeg" alt="Datong sunset glow"></p>
<p>The experience afterward confirmed that impression. Many places in Datong have been demolished and rebuilt. Many one-story houses in the city center were torn down and replaced with new apartment buildings, and you could still see rubble from recently demolished buildings inside the urban area. We checked into the hotel on Friday, then went out for dinner, and were amazed by the cost of living. The boss picked a noodle shop with very refined decor. The waiters were uniformly dressed in black. When she paid, I got a bank card notification: 35 yuan. I asked, "Did you only order one bowl of noodles?" "No, two. Aren't you eating?" ... It was less than three hours away from the capital, and I was a bit unaccustomed.</p>
<h2 id="day-1" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2021/07/18/da-tong/en.html#day-1">Day 1</a></h2>
<p>For the second day's itinerary, our first stop was the Yungang Grottoes, about 15 kilometers by taxi from the city center, costing 31 yuan. The Yungang Grottoes were first built during the reign of Emperor Xiaowen of Northern Wei, and together with the Longmen Grottoes in Luoyang and the Mogao Caves in Dunhuang, they are known as China's three great grottoes. I haven't been to the Longmen Grottoes yet, though that was also built by Emperor Xiaowen of Northern Wei (which tells you how amazing he was). Compared with the Mogao Caves, I feel its artistic value is somewhat lower, but there are still many incredible stone carving techniques. In terms of cultural relic preservation, I felt it was far from as well done as at Mogao. There has already been quite a bit of weathering, and the restoration work didn't seem especially good. It's also worth mentioning that there are similarly some rather pointless additions from the Qing dynasty. There are plenty of photos online, so I won't put too many here.</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/yun-gang-shi-ku.7bd11078.jpeg" alt="Yungang Grottoes"></p>
<p>Because it is a Buddhist site, there is also a temple outside. The temple buildings are genuine wooden structures, which is quite interesting. The wind chimes hanging from the eaves have an antique charm. I had seen them before in Japan, which gave me a slight illusion and then turned into regret: this is clearly where Tang culture originated, yet it has not been preserved as well as it was passed down to Japan.</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/yun-gang-shi-ku-si-miao-1.04df697c.jpeg" alt="Yungang Grottoes temple"></p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/yun-gang-shi-ku-si-miao-2.a9cf8f12.jpeg" alt="Yungang Grottoes temple"></p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/dou-gong-feng-ling.677fa7eb.jpg" alt="Dougong and wind chime"></p>
<p>We had lunch at the entrance to the scenic area. It was way too salty—I could barely eat it—so we took a taxi back into the city. In Datong's old city, the ancient city wall has been completely rebuilt, and they also made a linear park. The single-story houses inside the inner city have also almost all been demolished. Starting from Huayan Temple and ending at Fahua Temple, we walked through the core area from west to east. Here's a map for reference:</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/da-tong-map.6aff4f8e.png" alt="Map of Datong old city"></p>
<p>Huayan Temple requires a 50-yuan ticket. Although we didn't go in, we could already see from outside the wall that its dougong brackets were delicate and beautiful. I wanted to go in, but the boss thought it was too expensive, and neither of us is that interested in temples, so we gave up.</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/hua-yan-si.83b67ce9.jpeg" alt="Huayan Temple"></p>
<p>To the east of the temple is a large commercial area built in an imitation-ancient style, but it was deserted and not many shops were open.</p>
<p>A little farther west, we came across a mosque. It was quite distinctive, a blend of Chinese and Western styles, but it didn't seem to be open, so we didn't go in either.</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/qing-zhen-si.78945f4f.jpeg" alt="Mosque"></p>
<p>Then there was the Four Memorial Archways—pretty average.</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/si-pai-lou.3e7c45a4.jpeg" alt="Four Memorial Archways"></p>
<p>On the way we passed what is said to be the largest Nine-Dragon Wall in the universe, even bigger than the one in Beijing.</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/jiu-long-bi-1.60237250.jpeg" alt="Nine-Dragon Wall"></p>
<p>This one was moved later by the Central People's Government of New China; originally it was the facade of the Prince Dai Mansion.</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/jiu-long-bi-2.351ee228.jpeg" alt="Nine-Dragon Wall"></p>
<p>North of the Nine-Dragon Wall is the Prince Dai Mansion, and this place is interesting. It is basically a replica of the Forbidden City, except that most of the roofs have been changed to blue-green—after all, it's "Dai." This place is now free to visit, but the entrance is a bit small and easy to miss, and there is also a free guide. Most of the Prince Dai Mansion was actually newly built in modern times after demolishing old houses, but I think it was rebuilt with great sincerity, because many newly built pseudo-ancient buildings today use cement for dougong and columns, while this place uses wood. The layout was rebuilt according to the original site. The reason the "Dai" prince's mansion could be built so large is that it belonged to Zhu Yuanzhang's favorite son. It was the early Ming dynasty, and there weren't many concrete regulations yet, so they basically built it as large as they wanted.</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/yu-men.d834dd19.jpg" alt="Yu Gate"></p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/dai-wang-fu-dou-gong.80ccd320.jpg" alt="Dougong of Prince Dai Mansion"></p>
<p>Not all the roofs are blue-green. The "Chengyun Hall" has a yellow roof. Doesn't it look like the Forbidden City?</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/cheng-yun-dian.896d3089.jpeg" alt="Chengyun Hall"></p>
<p>The scenic area is so niche that there were almost no people—basically a perfect place for young women to take photos.</p>
<p>Our last stop was Fahua Temple, a very clean and antique-looking temple.</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/fa-hua-si.1b33c34c.jpeg" alt="Fahua Temple"></p>
<p>We were lucky enough to capture a Buddha halo.</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/fa-hua-si-fo-guang.d720b6f9.jpeg" alt="Buddha halo at Fahua Temple"></p>
<p>To sum up, there actually isn't much of a lived-in atmosphere in the old city now. It has basically all been demolished, and rebuilding is only partly finished, while tourists are very few. The place with the strongest sense of life turned out to be Fahua Temple, the last place we visited, because the monks were still living there. Still, when we came out and saw a rabbit meat shop at the temple gate—my god, does Buddha know about this?</p>
<h2 id="day-2" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2021/07/18/da-tong/en.html#day-2">Day 2</a></h2>
<p>The plan for this day was the Hanging Temple and Mount Heng in the north. Hey, if I drag Miss Ren up Mount Heng, will little nun Yilin get jealous and hide from me?</p>
<p>The Hanging Temple is right below Mount Heng, about seventy or eighty kilometers from Datong. We had originally thought about joining a tour group, but after looking, all those one-day tours included the Yungang Grottoes, and the schedule was really rushed. Later, though, we realized that in fact it was more or less possible to do it all in one day (mist).</p>
<p>By the time we got up, had breakfast, and walked to the car rental place, it was already after 9 and almost 10. There was a little traffic leaving the city, and we didn't arrive in Hunyuan County for lunch until almost noon. From the county seat to the Hanging Temple is very close, and we got there in no time. Tickets for the Hanging Temple were cheap, but going up into the temple cost an extra 100, so we just looked at it from below. Actually, you can see it even from the parking lot.</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/xuan-kong-si-1.fa9a1382.jpeg" alt="Hanging Temple"></p>
<p>It is built tightly against the cliff, but from a distance you can actually see that it is no longer a wooden structure; it has long since been replaced by reinforced concrete. The inscription "Magnificent" was written by Li Bai. From another angle, you can see how truly precarious it is:</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/xuan-kong-si-2.e09ade23.jpeg" alt="Hanging Temple"></p>
<p>There is a river in front of the Hanging Temple. The water is not abundant now, and there is a hydropower station dammed upstream.</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/heng-shan-shui-ku.c74638ff.jpeg" alt="Mount Heng reservoir"></p>
<p>The 10-yuan parking fee told me I had only spent 40 minutes at the Hanging Temple. No wonder some people parked directly in the open space before the fee gate. We came back the same way, got onto the national highway, drove through the Hengshan Tunnel, and then went up Mount Heng. That national highway has been pretty badly worn down by heavy trucks, and Hengshan Scenic Area is right by the road, with a rather small parking lot too. Mount Heng is the only AAAA scenic area among the Five Great Mountains; the other four are all AAAAA. After going up and coming down, it did feel like that AAAA rating was a bit shaky.</p>
<p>At the foot of the mountain is the Hengshan Sect martial arts ground (though actually this is a Taoist temple, and Hengshan doesn't have a nunnery):</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/dao-guan-guang-chang.d445afa8.jpg" alt="Taoist temple square"></p>
<p>You can climb directly, of course, though the mountain is still fairly tall. You can also choose to take a cable car or a shuttle bus. The cable car goes to a point a bit over halfway up, and the bus to around halfway. In the photo below, there's still one-third of the climb left to the summit. The place indicated by the leaves on the treetop at the right is the entrance to the scenic area (the triangular open space to the left of the reservoir). The open area visible in the middle is where the uphill shuttle bus drops passengers off. This time, because Shanxi has had a lot of rain recently, the very top of the mountain was closed. It didn't feel as demanding as Xiangshan, and the whole hike including the descent took just over two hours.</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/heng-shan-overview.e61506f3.jpeg" alt="Mount Heng overview"></p>
<p>The tool person is climbing the mountain.</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/gong-ju-ren.f8755bd9.jpg" alt="The tool person climbing the mountain"></p>
<p>Imperial calligraphy by Emperor Kangxi.</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/kang-xi-heng-shan.b055bd1b.jpeg" alt="Imperial calligraphy by Emperor Kangxi"></p>
<p>Anyway, if you don't have high expectations, it's still okay.</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/heng-shan.937b11a1.jpg" alt="Mount Heng"></p>
<h2 id="other-things" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2021/07/18/da-tong/en.html#other-things">Other things</a></h2>
<p>Since we were in Shanxi, of course we still saw coal mines—including near the Yungang Grottoes, where you can also see coal mines and coal trains. This is the starting point of the famous "Daqin Railway," the heavy-haul coal railway from Datong to Qinhuangdao, which transports nearly 20% of China's coal and contributes nearly 10% of the country's electricity generation. It is also a listed stock. There is a rumor that trains from Datong to Qinhuangdao hardly consume electricity at all, and that electricity generated by locomotive braking can even make power consumption negative.</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/mei-kuang.6b4b9280.jpg" alt="Coal mine"></p>
<p>The Loess Plateau really is full of ravines and gullies. Every now and then there's a huge deep pit, and vegetation is not abundant, so soil erosion is indeed real.</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/huang-tu-gao-yuan.46935f67.jpeg" alt="Loess Plateau"></p>
<p>Datong's consumption level is very low. It feels like local incomes aren't high either, but you can clearly sense that the local government has far more money, demolishing and rebuilding ancient architecture everywhere. Thinking about it, that makes sense: this is a resource-based city. Aside from a few coal bosses, many of the mines belong to the government, so ordinary people indeed don't have many special opportunities. The government's demolition and rebuilding is understandable too. After all, resources will be depleted one day, so while there is money now, investing early in some tertiary industries is also a path toward sustainable development.</p>
<p>And for friends in Beijing, now that the Beijing-Zhangjiakou high-speed rail is open, Datong really can be a weekend travel destination. It isn't exceptionally outstanding, but if your expectations aren't too high, the experience is still okay. Given Datong's current prices, I even feel like the trip delivered some solid value for money.</p>
]]></content>
        <author>
            <name>Yingyu Cheng</name>
            <email>emerald_cahoots0j@icloud.com</email>
            <uri>https://github.com/winguse</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[Notes on Tinkering with the Xiaomi 11 Pro]]></title>
        <id>https://wingu.se/2021/06/14/xiaomi/en.html</id>
        <link href="https://wingu.se/2021/06/14/xiaomi/en.html"/>
        <updated>2021-06-11T15:00:00.000Z</updated>
        <summary type="html"><![CDATA[

Ten years ago I was a flashing-ROM boy when using Android, and ten years later I'm still that same...]]></summary>
        <content type="html"><![CDATA[<p><img src="https://winguse.com/view-counter?r=wingu.se/2021/06/14/xiaomi/en&from=feed" style="vertical-align: middle; height: 1em;"/></p>
<blockquote>
<p>Ten years ago I was a flashing-ROM boy when using Android, and ten years later I'm still that same flashing-ROM boy.</p>
</blockquote>
<p>That sentence is almost the best summary of my Dragon Boat Festival holiday.</p>
<p>A week before that, I bought a Xiaomi 11 Pro. It wasn't really because I needed a new phone; I just wanted to tinker a bit and see what the Android ecosystem has developed into nowadays. Also, having a device that can run Linux means you can play with lots of other tricks too. Of course, since it was discounted for the 618 sale and I happened to be on a business trip in Beijing, I could use Beijing consumer coupons, so I got this 8 + 128GB device for less than 3900 yuan.</p>
<p>Since I bought it to tinker with, of course I wanted to root it. But these days you can't root it directly. Unlocking has restrictions: you first need to bind the phone to your Xiaomi account and then wait 168 hours (7 days). You can refer to the <a href="https://www.miui.com/unlock/index.html" target="_blank" rel="noopener noreferrer">official tutorial</a> for details.</p>
<p>I wasn't in a hurry, so I first spent some time experiencing the original Chinese MIUI. MIUI still has many features, just like in the old days, but it really also has ads everywhere. It made me feel more and more that the iPhone 12 mini in my hand is truly great. The Android ecosystem is too troublesome to put your mind at ease, although it does feel much better than before. Domestic apps are equally intense, full of flashy recommendations and noise, but on iOS they are still somewhat more restrained. As for Xiaomi, I think the current experience is still some distance away from truly high-end. The features are indeed very down-to-earth, and some of them surprised me, but many details still need polishing. For example, the white balance of the three cameras is inconsistent. By comparison, the tuning on iOS is simply incredible. Software quality on iOS has declined in recent years, but it is still one or two body lengths ahead of MIUI. I've also been paying some attention recently to Huawei's HarmonyOS, and I think Xiaomi's investment in R&amp;D may really still be insufficient. The advertising problem is also a hurdle on its high-end path. From my experience, it's true that most ads can be turned off, but they're all hidden very deeply. Xiaomi's business strategy is also awkward: if it wants an internet-company valuation, it needs internet business, and ads seem to be one of the few ways to monetize that side. But in fact the revenue contribution doesn't seem that large, so I think it's kind of a chicken rib—of course, I don't know what Xiaomi's big bosses think. I also don't know why, but after installing Google Play, I still couldn't download apps. While debugging, I found that it probably wasn't my network, but I also noticed that the system sends requests to way too many bizarre domains during normal operation. In terms of privacy and security, it really doesn't inspire much confidence.</p>
<p>A week later came the three-day Dragon Boat Festival holiday, and the tinkering began. The unlocking process has an official tutorial, so I won't go into it. My first choice for flashing was the international version (which actually feels like the US version), but later I found that MIUI hadn't updated to 12.5 yet, so I switched to the European version, which is said to update faster and to be more restrained about privacy, with fewer ads. I also won't go into the specific flashing tutorial, but one point worth mentioning is that you need to download the full ROM package, and there is also an <a href="https://c.mi.com/oc/miuidownload/detail?guide=2" target="_blank" rel="noopener noreferrer">official tutorial</a>. The unlocking tool has to be used on Windows, but I verified that flashing can also be done on macOS with some minor script modifications. Just be careful not to re-lock the device.</p>
<p>Since I'd already flashed it, of course I still wanted to play with root. The popular tool these days is <a href="https://github.com/topjohnwu/Magisk" target="_blank" rel="noopener noreferrer">Magisk</a>. Note that the <code>.com</code> website that comes up in Google search does not belong to the author; the author's only page is the one on GitHub. It seems the download still comes from GitHub, but it's safer to go to GitHub directly. The process is explained clearly in the <a href="https://topjohnwu.github.io/Magisk/install.html" target="_blank" rel="noopener noreferrer">Magisk documentation</a>, so I won't translate it. One special reminder: when installing Magisk modules, make sure <code>adb</code> is enabled first, and use a computer to connect once so the phone trusts it. When things get messed up, that can sometimes save your life.</p>
<p>The European version of MIUI actually lacks many useful features, such as:</p>
<ul>
<li>Transit cards and access cards</li>
<li>Xiaomi App Store</li>
<li>Advanced permission controls such as flares</li>
</ul>
<p>In theory these can be restored through Magisk, so I spent some time tinkering with that too. In the end, though, I only got transit cards, access cards, and the Xiaomi App Store working; I couldn't get anything else to work. I'm not sure whether that's because the European version is currently <code>12.5.3</code> while the mainland version is <code>12.5.4</code>, or for some other reason. In particular, when I tried to restore the permission controls, the phone directly became unable to boot, and even using <code>adb</code> to go in and uninstall the module didn't help.</p>
<p>There are several articles online explaining how to restore transit cards and access cards. I tested them, and perhaps because this is now a newer version, following those steps still didn't work for me. After opening Xiaomi Wallet, tapping on access cards or transit cards did nothing, so I did some tinkering myself.</p>
<p>What is described online is all based on older ways of creating Magisk modules. The newer version is actually very simple and doesn't need that many files. You can refer to the <a href="https://topjohnwu.github.io/Magisk/guides.html" target="_blank" rel="noopener noreferrer">documentation</a> for details; I'll just describe it briefly here.</p>
<p>Create any folder you like, and create a new file <code>module.prop</code>, for example:</p>
<pre><code>id=mi_smart_card
name=Xiaomi Smart Card
version=v0.0.1
versionCode=1
author=Yingyu
description=Add MIUI CN Features to 11 pro
</code></pre>
<p>Find the corresponding mainland China MIUI version and extract the corresponding apps from <code>/system/app/</code>. I have no interest in using UnionPay cards on this phone, so I felt I didn't need to restore that functionality. I tested it and found that if I only wanted transit cards and access cards, I just needed to restore <code>TSMClient</code>. One thing worth noting is that inside <code>/system/app/TSMClient/lib/arm64</code> there are two symbolic links, and you also need to copy the files they point to, namely <code>libentryexpro.so</code> and <code>libuptsmaddonmi.so</code> from <code>/system/lib64</code>. Of course, considering that some apps aren't available on Google Play, I also restored the Xiaomi App Store, <code>MiuiSuperMarket</code>.</p>
<p>For transit cards and access cards, you need to change the NFC <code>Security element settings</code> in system settings to <code>Embedded secure element</code>. However, the European version does not have this option. To restore it, you need to modify the system prop. Specifically, create a new <code>system.prop</code> file in the root directory with the following contents:</p>
<pre><code>ro.se.type=eSE,HCE,UICC
</code></pre>
<p>Zip up the contents of the folder above, download it to the phone, and in Magisk choose <code>Install from storage</code>.</p>
<p>After installation, this module will only add a Xiaomi App Store icon to the home screen. However, you still won't see any trace of access cards or transit cards. We need to create shortcuts for them. Here we use <a href="https://play.google.com/store/apps/details?id=rk.android.app.shortcutmaker" target="_blank" rel="noopener noreferrer">the Shortcut app</a>. After downloading it, go to Activities and create several shortcuts for the Xiaomi Smart Card app, namely:</p>
<ul>
<li>Access card: <code>com.miui.tsmclient.ui.MifareCardListActivity</code></li>
<li>Transit card: <code>com.miui.tsmmclient.ui.introduction.CheckServiceActivity</code></li>
<li>Double-click power interface: <code>com.miui.tsmclient.ui.quick.DoubleClickActivity</code></li>
</ul>
<p>Among them, the double-click power one must be enabled before you can register and use it from the lock screen by double-clicking. The other interfaces can be used normally. For transit cards, you need to log into your Xiaomi account in system settings; after binding a transit card, only then can you have more than two access cards.</p>
<p>I replaced all of Xiaomi's cloud services and also turned off Find Device, but when I tried to use adb to uninstall this app, the phone became unable to boot. Yet this thing tirelessly stays active in the background and even pushes notifications to me. I don't have any good solution, so I can only turn off its notifications... As for the notification, it even impersonates someone else (this notification appeared right after WeChat was installed):</p>
<p><img src="https://wingu.se/_file/images/2021-06-14-xiaomi-find-device.9cc31083.jpeg" alt="A Xiaomi Find Device notification appeared right after WeChat was installed"></p>
<p>No choice, I can only lie flat...</p>
<h2 id="notes-for-extract-img" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2021/06/14/xiaomi/en.html#notes-for-extract-img">Notes for extract img</a></h2>
<ol>
<li>download and extra from <a href="https://www.xiaomi.cn/post/25769526" target="_blank" rel="noopener noreferrer">https://www.xiaomi.cn/post/25769526</a></li>
<li><code>brew install simg2img</code> and <code>simg2img images/super.img out_super.img</code></li>
<li><a href="http://newandroidbook.com/tools/imjtool.html" target="_blank" rel="noopener noreferrer">http://newandroidbook.com/tools/imjtool.html</a> <code>imjtool/imjtool out_super.img extract</code></li>
<li><code>ext4fuse extracted/system_a.img sysa -o allow_other</code></li>
</ol>
<p><a href="https://medium.com/@chmodxx/extracting-android-factory-images-on-macos-cc61e45139d1" target="_blank" rel="noopener noreferrer">https://medium.com/@chmodxx/extracting-android-factory-images-on-macos-cc61e45139d1</a></p>
<p>also see: <a href="https://blog.minamigo.moe/archives/184" target="_blank" rel="noopener noreferrer">https://blog.minamigo.moe/archives/184</a></p>
]]></content>
        <author>
            <name>Yingyu Cheng</name>
            <email>emerald_cahoots0j@icloud.com</email>
            <uri>https://github.com/winguse</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[Trying Out Clubhouse]]></title>
        <id>https://wingu.se/2021/02/06/clubhouse/en.html</id>
        <link href="https://wingu.se/2021/02/06/clubhouse/en.html"/>
        <updated>2021-02-03T12:00:00.000Z</updated>
        <summary type="html"><![CDATA[
Recently, because of a tweet by Elon Musk, this app also became popular in my circles, mainly Chine...]]></summary>
        <content type="html"><![CDATA[<p><img src="https://winguse.com/view-counter?r=wingu.se/2021/02/06/clubhouse/en&from=feed" style="vertical-align: middle; height: 1em;"/></p>
<p>Recently, because of a tweet by <a href="https://twitter.com/elonmusk/status/1355983231988862978?s=20" target="_blank" rel="noopener noreferrer">Elon Musk</a>, this app also became popular in my circles, mainly Chinese Twitter and the tech world. Of course, this Clubhouse is not the <a href="https://clubhouse.io/" target="_blank" rel="noopener noreferrer">ticketing system</a> used by my company. After being invited by a colleague, I finally got access and started trying it out.</p>
<h2 id="product-form" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2021/02/06/clubhouse/en.html#product-form">Product form</a></h2>
<p>This app currently uses an invitation system. After a person registers, they can immediately get 2 invitations (though later it seems not everyone got them anymore). It also displays invitation credit, and they even put this into everyone's profile, thereby publicly exposing a binary-tree-like relationship chain across the whole internet, where the root of each tree is some original seed user.</p>
<p>Each Clubhouse user has a unique ID, name, avatar, and can write a profile introduction. They can link Twitter and Instagram, and they can follow others or be followed.</p>
<p>Clubhouse's help documentation is hosted on Notion.</p>
<p>After registering for Clubhouse, users can choose fields they are interested in, and within each field there are individual Clubs that users can choose to follow. It seems the Chinese community hasn't really started seriously exploring this part yet. It seems you have to host several rooms before you can create a Club.</p>
<p>Everyone can host a Room. It can be private, it can allow only people you follow to join, or it can be completely public. In a Room, there can be the owner and moderators. People in the room can raise their hands to speak, and the room owner can decide who is allowed to raise their hand—for example, everyone, followed users, or nobody.</p>
<p>A user's home screen shows Rooms that are currently happening, based on recommendations, follows, or because people they follow are inside. There is also a calendar showing upcoming events.</p>
<p>The notification management for users is fairly complete—for example, being followed, scheduled events, friends from your contacts joining, and so on.</p>
<p>Aside from voice, the product has no other communication methods—there is no text or image support. As a result, users also cannot communicate concurrently inside the app. But when you're in a room, you can still leave and wander around elsewhere.</p>
<p>As for content, everything is real-time and rather casual, so there won't be especially high-quality content (at least for now). For me, I often feel that other people speak too slowly, and without playback speed control it wastes time. At the same time, because there is no concrete text introduction, if you enter midway you won't immediately know the topic and need a long time to bootstrap. The content itself is also not recorded, so it is hard for anything to accumulate. It is more of a discussion tool with social attributes. Perhaps brainstorming is a good scenario for it.</p>
<p>Technically it is still impressive. The audio quality is good, and when switching networks it can reconnect quickly. It uses <a href="https://www.agora.io/" target="_blank" rel="noopener noreferrer">Agora</a>'s technology, and Agora's stock doubled within one day. So Clubhouse the company itself does not actually control the core technology.</p>
<h2 id="what-problem-did-clubhouse-solve-that-others-didn-t" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2021/02/06/clubhouse/en.html#what-problem-did-clubhouse-solve-that-others-didn-t">What problem did Clubhouse solve that others didn't?</a></h2>
<p><strong>A blank area in the move toward video.</strong> I think one thing it solved is this: people say it's the 5G era and everything is going video, but video is not suitable for many scenarios—for example, when we have meetings, we don't turn on video. This voice-only dimensionality reduction captures those who still don't want video. At the same time, when listening, people can keep doing many other things, like walking, cooking, or driving. This product form is a bit like radio programs from many years ago, where you could call in and chat with the host.</p>
<p><strong>A social network for real-time communication.</strong> Here we can first compare it with existing online audio and video solutions. They are either point-to-point private meetings and online teaching, or point-to-many livestreaming. The former is private, non-public, and real-time; the latter is mostly public and almost real-time (with current livestreaming technology, there is at least a delay measured in seconds). We might compare Clubhouse with game voice platforms like YY, but there is still some difference between them: the user groups targeted by the two products are different. Products like YY can penetrate well among gamers, but penetration among ordinary social users is much worse. This still comes down to differences in product form. Clubhouse was built for social interaction from the very beginning.</p>
<p><strong>Harder to pollute with information.</strong> Compared with text, speaking has a much higher cost because it requires a real person to speak in real time. Compared with video, the participation cost for ordinary people is lower. So if someone wants to use paid posters or bot armies, this kind of place is much harder.</p>
<h2 id="what-is-special-about-the-chinese-speaking-world" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2021/02/06/clubhouse/en.html#what-is-special-about-the-chinese-speaking-world">What is special about the Chinese-speaking world?</a></h2>
<p>There is still a threshold for users in mainland China who want to use this: you need iOS and also a non-mainland Apple ID. So the people who got in first were all from the tech world. Precisely because of that, it narrowed the distance between nobodies like me and big shots.</p>
<p>On the first night, I listened to several circles: one was mainly China's investment circles and product managers, and they mostly discussed how this product could operate in China and how it could sink into third- and fourth-tier cities. But inevitably they also talked about feasibility: this product is too hard to regulate, so several product people were not optimistic. They also mentioned monetization, while some investors were still observing and felt that because it was breaking out of its niche too quickly, they could first cultivate the soil and maybe there would be bigger possibilities later. Another room was hosted by Flypig, an internet celebrity who brings traffic wherever he goes. I also listened to the first room selling stuff (voluntarily), sharing what kind of happiness 3000 yuan could buy, and I bought an app called AutoSleep, which turned out to be pretty useful. Flypig said something interesting: with this damned invitation code, plus requiring iOS and an overseas account, huge piles of his followers who were VCs switched from Huawei to iPhone overnight. There was also a Hong Kong product manager circle, where naturally the talk was only about products. Compared with circles inside the mainland, they didn't discuss regulation. It felt like the hottest thing in the Chinese-speaking world was still political rooms. Having lived for more than thirty years, it was the first time I had ever seen a social large-scale discussion of several thousand people that went on until three in the morning.</p>
<p>Because of regulation, people in China have already started making copycats. But products can be copied; this group of people is hard to copy. That's very true, because some people came precisely for the borderless aspect. If the domestic internet made one, I don't think they would come back.</p>
<h2 id="so-what-is-it-useful-for" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2021/02/06/clubhouse/en.html#so-what-is-it-useful-for">So what is it useful for?</a></h2>
<p>I also don't know what form this product will eventually evolve into. I think in the end content will still be king, and there should still be some users who regularly share content. But it can also be an opportunity for socializing with strangers—for example, casually passing by a coffee shop, chatting for a bit, finding common interests, or simply relieving loneliness. Or perhaps it could be a city forum.</p>
]]></content>
        <author>
            <name>Yingyu Cheng</name>
            <email>emerald_cahoots0j@icloud.com</email>
            <uri>https://github.com/winguse</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[Daily Rambling]]></title>
        <id>https://wingu.se/2020/06/13/some-random-words/en.html</id>
        <link href="https://wingu.se/2020/06/13/some-random-words/en.html"/>
        <updated>2020-06-10T12:00:00.000Z</updated>
        <summary type="html"><![CDATA[
Come to think of it, 2020 is already the tenth year since I started writing this blog. Unfortunatel...]]></summary>
        <content type="html"><![CDATA[<p><img src="https://winguse.com/view-counter?r=wingu.se/2020/06/13/some-random-words/en&from=feed" style="vertical-align: middle; height: 1em;"/></p>
<p>Come to think of it, 2020 is already the tenth year since I started writing this blog. Unfortunately, I have not written anything especially useful. It has also been a long time since I last updated it, so today I will just ramble a bit in a stream-of-consciousness way.</p>
<p>This morning, I kept watching the newly added pneumonia cases in Beijing. This time it really feels like a crisis, because now it is very close to me. In particular, 1,900 people were tested yesterday, and this morning data from more than 500 samples was released, with 45 positive results among them. A rate close to 10% is honestly frightening. Besides, there had already been no local cases in the whole country for quite a while, and in the end the capital was where things went wrong... Of course, if I need to go out, I still have to go out, and I still need to go eat. If you ask whether people seem worried, the street scene does not really look any different, except that some security guards have once again started weakly checking entry permits and body temperatures from time to time. After all, Beijing is very large, and even Chaoyang District is very large. And given the state of the world right now—doing a bit of Ah Q-style self-comfort—if you look at the overseas situation, Beijing's single-digit numbers still seem several orders of magnitude smaller...</p>
<p>Speaking of this pandemic, honestly, its impact on me in the short to medium term has been quite large. For quite a long time recently, I have felt extreme uncertainty about the future, and I have also been somewhat anxious—I do not know where I should go. It is just that I do not really like the Ah Q mentality. The uncertainties I am talking about are only changes in work location and daily life. Compared with many other people, that is really nothing. On the other hand, there is also my anxiety about the terrible state of the world: the global spread of the pandemic, so many people living in hardship; the worsening global political environment, as China and the U.S. move from cooperation toward confrontation, populism runs rampant, and some people mindlessly embrace "accelerationism." The world is in such a terrible state, and yet I can do nothing about it, which only lets that sense of powerlessness run even more wild in my heart...</p>
<p>This afternoon, I watched a Bilibili creator talking about U.S. military bases in Japan and South Korea. Later I looked into it in more detail and discovered that the U.S. military is basically stationed all over the world. Then I casually read some articles about U.S. overseas territories, and eventually found this page, <a href="https://zh.wikipedia.org/wiki/%E7%BE%8E%E5%9C%8B%E7%AC%AC51%E5%B7%9E" target="_blank" rel="noopener noreferrer">51st state</a>, which I found very interesting. I had no idea that so many places in the world want to become the 51st state of the United States. Of course, the term is also used sarcastically in that article, to mock how heavily Americanized some places have become. But <a href="https://zh.wikipedia.org/wiki/%E6%B3%A2%E5%A4%9A%E9%BB%8E%E5%90%84" target="_blank" rel="noopener noreferrer">Puerto Rico</a> in particular caught my attention. Its current status is that of an autonomous commonwealth under the United States. If they wanted to become an independent country, they could, but after several referendums, support for joining the U.S. as a state has continued to rise. But the U.S. Congress does not want it to join, because adding one more state would dilute the voting power of the others. That gave me a certain feeling—the sense of many nations coming to pay tribute in a truly meaningful way. That is what real national strength looks like. Perhaps this is what China felt like during the Tang dynasty? I do not want to expand here into a discussion of the differences between China and the U.S. in this respect; I just wanted to share today's discovery.</p>
<p>Since graduating, all my jobs have been at wholly owned China subsidiaries of American companies. Over the past two years, during this period of China-U.S. confrontation, my colleagues and I have more or less all asked leaders at headquarters about the impact on our Beijing team. Of course, because our company currently has no business operations inside China and is just an R&amp;D team, and because the company is indeed quite small, the line "We are too small to mater" is usually enough to brush the question aside. I would suggest that we use gentler words in some cases—for example, saying "Beijing" and "Washington" is better than saying "China" and "the United States." Our SFO colleagues are actually always very polite and friendly toward the PEK colleagues. Sometimes I feel that we are all just small people living under two great powers, yet there are still many subtle ways in which we affect things between them, or in other words, we bear some responsibility. As the saying goes, "Discrimination comes from prejudice, and prejudice comes from ignorance." People like us, ordinary small people, actually do have some obligation to help the two sides understand each other better, to communicate, and ultimately to reduce confrontation. Unfortunately, many people are unable to see this. Especially in recent years, as populism has grown, people get hot-headed and immediately turn everything into grand ideological struggle, wolf-warrior diplomacy, great-power rise, and punishing enemies no matter how far away; or they just chase a moment of verbal cleverness and casually mock the other side, creating misunderstanding and confrontation. In times like this, what we really need is: "The more you feel you are about to get emotional, the more you need to stay calm."</p>
<p>Anyway, peace and development are still the ultimate themes of this world. May the world around us recover soon.</p>
]]></content>
        <author>
            <name>Yingyu Cheng</name>
            <email>emerald_cahoots0j@icloud.com</email>
            <uri>https://github.com/winguse</uri>
        </author>
    </entry>
</feed>