⌂ Home

About


pinta.se is a hobby project I started during the Covid-19 isolation, with the purpose of me trying to learn to setup and maintain a LAMP server (a Linux - Apache - MariaDB - Python - server).

To fill it with something hopefully more useful than just "Lorem ipsum dolor…", I here share my experience of the learning process, similar to a blog or a lab notebook, together with some other information I've made or collected over the years.

Linux

First step wasn't the OS, but the HW. I bought a Raspberry Pi 4 for the purpose, which runs a Debian fork. I took the free geek-university.com Linux online course, and since then I pretty much duckduckgo any problem I face.

I wanted to be able to use the Linux computer as my private personal computer, not to depend on any windows computer at all. And for a few years my Raspberry Pi 4 did the trick.

I already had a lot of my personal data on Dropbox, so I wanted to use it for both personal data and site backup. But, I found Dropbox wasn't an easy install on Raspberry. The saviour was Rclone, which I run using some convenient Linux aliases.

LibreOffice and Python was already preinstalled on my Raspberry Pi. I added Keepass for passwords, and XScreenSaver. I tried Mirage image view/edit software, but eventually changed to Nomacs since Mirage is no longer maintained. I wanted a markdown text editor with spell check and added ghostwriter for it.

I had quite a struggle getting my logitec C270 webcam working, but eventually succeeded, which I could verify with Cheese. Steps to success included listing microphones with arecord -l and sudo nano .asoundrc to:

pcm.!default {
  type asym
  playback.pcm {
    type plug
    slave.pcm "output"
  }
  capture.pcm {
    type plug
    slave.pcm "plughw:2,0"
  }
}

pcm.output {
  type hw
  card 0
}

ctl.!default {
  type hw
  card 0
}

I quite soon got annoyed by the loud noise from the fan my Raspberry came with. Placing a pillow on top of it was OK for a while, but something more permanent had to be done. As the Raspberry Pi comes with general purpose I/O pins, possibilities were at hand. I made a small switching circuit for the fan, with a flyback diode, and a Python script to control it from the GPIO.

circuit

#!/usr/bin/python3

# add: sudo python3 /path_to_this_python_file.py &
# to boot sequence with: sudo nano /etc/rc.local

import RPi.GPIO as GPIO
import time, sys

# Setup GPIO21 pulse width modulation
# (@ J8 pin 40 - see linux cmd 'pinout')
GPIO.setmode(GPIO.BCM)
GPIO.setup(21, GPIO.OUT, initial=GPIO.LOW)
fan = GPIO.PWM(21, 25) #25 Hz 
fan.start(0)
minspeed = 25
oldspeed = 0

try:
    while True:
        # Read CPU temperature
        TempFile = open('/sys/class/thermal/thermal_zone0/temp', 'r')
        CPU_temp = int(int(TempFile.read()) / 1000)
        TempFile.close()
        if CPU_temp < 50:
            speed = 0
        elif CPU_temp >= 70:
            speed = 100
        else:
            speed = (minspeed + int((100-minspeed)*(CPU_temp - 50)/20))
        if speed != oldspeed:
            fan.ChangeDutyCycle(speed)
            oldspeed = speed
        time.sleep(3)
except KeyboardInterrupt:
    print('Interrupted by ctrl-c')
    
GPIO.cleanup()
sys.exit()

By now I had a working Linux computer. ☺

After a couple of years I decided to secure some accounts with a HW-key, and needed Yubico Authenticator, which wasn't available for Raspberry pi, so I decided to switch to an AMD64 computer and Ubuntu. This also removed the need for the Rclone Dropbox workaround, and improved speed when browsing internet or doing spreadsheet calculations.

Apache

I took the free geek-university.com Apache HTTP server course and installed Apache.

You really need a fix IP-address for your server, and luckily my internet provider offered one at no additional cost. I configured my router to dedicate a fix local IP address to my Linux computer, and to forward port 80 and 443 to it. Then I published my first web pages on this server:

Doing so, I realized I had to make the links page with one or two columns depending on browser width. So I needed to gain some more knowledge in HTML and CSS, and found plenty at www.w3schools.com.

I also found blue hyperlinks on a white background can be quite irritating in late evenings, so I wanted to reduce blue and make a more harmonic color scheme. Less blue in white brings you to "LightYellow". Close by "Wheat" looks nicer IMO with even lesser blue. I also needed a darker color for hyperlinks, and one for visited hyperlinks. With Adobe color wheel I selected the triad combination used here.

color wheel

Looking at the Apache log files I could see many requests for something called "favicon" and "robots.txt", so I searched for information about it, and decided to add such too. I use LibreOffice to make favicons, draw them on a 16×16 canvas and export selected… also works to make svg's.

At this point I decided to try making a dollar on the site by adding ads, and realized I needed a domain name for that. Ad companies don't accept IP-address only servers. So, I had to invest in a domain name, and after that I could get the ads. But another problem surfaced: the ads linked to porn when I was outside of my LAN. I couldn't see any sign of break-in on my server, so someone probaby corrupted the ad scrips along the way, inserting porn ads instead. I hadn't thought I needed SSL as no information is sensitive, but I was proved wrong. Very well, with certbot and after some difficulties I finally got the encryption working. Lot's of struggle to add ads, and so far it haven't paid for the domain name. And, with the current site traffic it never will, so I have removed the ads.

Python

First use of Python was for the fan speed control (but that's outside the LAMP scope).

Server side python script

The "glue" I use between Python and Apache is mod_wsgi and Flask.

In /etc/apache2/sites-enabled/ I have:

	WSGIDaemonProcess pinta_wsgi lang='en_US.UTF-8' locale='en_US.UTF-8'
	WSGIProcessGroup pinta_wsgi

	WSGIScriptAlias /calc /_path-to_/calc/wsgi.py
	<Directory /_path-to_/calc/>
	Require all granted
	</Directory>

and in /_path-to_/calc/wsgi.py I have:

#!/usr/bin/python3
import sys
sys.path.append('/home/<user>/.local/python3.xx/site-packages/')
sys.path.append('/_path-to_/calc/')
from HelloFlask import app as application

And, the Flask Python code is located in /_path-to_/calc/HelloFlask.py .

Two useful Flask tutorials are Tutorialspoint Flask and The Flask Mega Tutorial. When I switched to Ubuntu I run into an encoding problem and had to switch to daemon mode.

The first server side python script services I published are:

And later on I added a markdown editor.

Client side python script

When I published "Sångblad" (in Swedish) I thought it would be nice with a script serving different pictures depending on the time of year, but when I thought more about it, it made more sense to implement it as a client side script. So, I made a version with a small Java script for it, but then I came across Brython, and remade it with browser python instead.

Next, I thought I'd have the front page in both Swedish and English, depending on browser setting, and made a script for it too. (Almost, with one javascript work-around).

Client side windows app

I continued with making a windows app out of my personal time management python script, and making it available to the public as Pinta Planner.

First step was making a windows app from it, using pyinstaller which is a simple one liner command:

pyinstaller --add-data "favicon.ico;." --icon favicon.ico pinta_planner.pyw

But making an install.exe using NSIS was more complicated. You have to write a script in yet another language. I finally got it working with:

OutFile "pinta_planner_installer.exe"
Unicode True
InstallDir "$PROGRAMFILES\pinta_planner"

Section
RMDir /r "$SMPROGRAMS\Lajfhakk"
RMDir /r "$PROGRAMFILES\Lajfhakk"
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Lajfhakk"

SetOutPath $INSTDIR
WriteUninstaller "$INSTDIR\uninstall.exe"
File /r "..\..\..\..\Grund\dist\pinta_planner\*.*"

WriteRegStr HKLM \
    "Software\Microsoft\Windows\CurrentVersion\Uninstall\pinta_planner" \
    "DisplayName" "pinta_planner"
WriteRegStr HKLM \
    "Software\Microsoft\Windows\CurrentVersion\Uninstall\pinta_planner" \
    "UninstallString" "$\"$INSTDIR\uninstall.exe$\""

CreateDirectory "$SMPROGRAMS\pinta_planner"
CreateShortCut "$SMPROGRAMS\pinta_planner\pinta_planner.lnk" \
    "$INSTDIR\pinta_planner.exe"
CreateShortCut "$SMPROGRAMS\pinta_planner\Uninstall.lnk" \
    "$INSTDIR\uninstall.exe"
SectionEnd

Section "Uninstall"
Delete "$INSTDIR\uninstall.exe"
RMDir /r "$SMPROGRAMS\pinta_planner"
RMDir /r $INSTDIR
DeleteRegKey HKLM \
    "Software\Microsoft\Windows\CurrentVersion\Uninstall\pinta_planner"
SectionEnd

Later I read about tkinter ttk widgets, pycodestyle and pylint, got inspired and decided to rewrite the GUI, into this:

from tkinter import (
    Tk, ttk, Menu, BooleanVar, Canvas, Text, Toplevel, filedialog, messagebox)
import webbrowser
import urllib.request
from os import listdir, remove
from os.path import join, isfile, isdir, expanduser
from datetime import date, datetime, timedelta
from tzlocal import get_localzone
from tkcalendar import Calendar
import babel.numbers  # only needed by pyinstaller
# import ctypes


#...

class PintaPlannerGUI(Tk):
    ''' GUI '''
    # pylint: disable=too-many-instance-attributes, broad-except
    def __init__(self, opt=False):
        # pylint: disable=too-many-locals, too-many-statements
        Tk.__init__(self)

        # ctypes.windll.shcore.SetProcessDpiAwareness(1)
        # does prevent blurry screen on win laptop - but mess up font sizes

        self.geometry('1000x600')  # default size

        try:
            self.iconbitmap('favicon.ico')
        except Exception:
            pass

        self.title('Pinta Planner')
        self.columnconfigure(0, weight=1)
        self.rowconfigure(0, weight=1)

        master_frame = ttk.Frame(self)
        master_frame.grid(column=0, row=0, sticky='news')
        master_frame.rowconfigure(1, weight=1)
        master_frame.columnconfigure(0, weight=1)

        ttk.Style().configure('TCombobox', background='white')

        self.option_add('*tearOff', False)
        menubar = Menu(self)
        self['menu'] = menubar
        menu_file = Menu(menubar)
        menubar.add_cascade(menu=menu_file, label='Files')
        menu_file.add_command(
            label='Reset top folder', command=self.folder_reset)
        menu_file.add_command(label='Save & sort', command=self.save)
        menu_file.add_command(label='Quit', command=self.destroy)
        menu_view = Menu(menubar)
        menubar.add_cascade(menu=menu_view, label='View')
        menu_view.add_command(label='All', command=self.set_all)
        menu_view.add_command(label='None', command=self.clear_all)
        menu_view.add_separator()
        menubar.add_command(label='Help', command=menu_help)

        # check for new version
        try:
            remove(join(expanduser("~"), 'lajfhakk.conf'))
        except FileNotFoundError:
            pass
        current = datetime.fromisoformat('2022-10-28')  # version date
        try:
            online = datetime.fromisoformat(urllib.request.urlopen(
                'https://pinta.se/planner/ver.txt', data=None,
                timeout=10).read().decode('utf-8'))
        except Exception:
            online = current
        if current < online:
            messagebox.showinfo(message='Download new version.')
            webbrowser.open_new_tab(
                'https://pinta.se/planner/index.html#download')
            if (date.today() > (online + timedelta(days=30))):
                return  # prevent running on old version forever.
        self.txt, self.root = what_to_read(opt)

        if self.txt == 'error':
            remove(join(expanduser("~"), 'pinta_planner.conf'))
            messagebox.showinfo('Folder reset, restart Pinta Planner')
            self.destroy()

        self.to_write = set()
        self.create_ap_list()

        self.event_list = []
        self.file_active = {}
        self.lbl = []

        for file in self.txt:
            self.file_active[file['file']] = BooleanVar()
            self.file_active[file['file']].set(True)
            menu_view.add_checkbutton(
                label=file['file'][len(self.root):],
                variable=self.file_active[file['file']],
                command=self.update)

        self.file_len = (max(len(self.txt[n]['file'])
                             for n in range(len(self.txt))) - len(self.root))

        header = ['Type', 'Postpone Date', 'Prio', 'Description', 'File']
        head_frame = ttk.Frame(master_frame)
        head_frame.grid(columnspan=2, sticky='ew')
        head_frame.columnconfigure(3, weight=1)
        for col, col_txt in enumerate(header):
            head = ttk.Label(head_frame, text=col_txt,
                             relief='ridge', padding=2)
            head.grid(row=0, column=col, sticky='ew')

        self.canvas = Canvas(master_frame)
        self.content_frame = ttk.Frame(self.canvas)
        self.content_frame.columnconfigure(3, weight=1)
        scroll = ttk.Scrollbar(
            master_frame, orient='vertical', command=self.canvas.yview)
        scroll.grid(row=1, column=1, sticky='ns')
        self.canvas.config(yscrollcommand=scroll.set)
        self.canvas.bind_all('<MouseWheel>', self.wheel)
        self.canvas.bind_all('<4>', self.wheel)
        self.canvas.bind_all('<5>', self.wheel)
        self.canvas.create_window(
            (0, 0), window=self.content_frame, anchor='nw',
            tags=('content_frame',))
        self.canvas.grid(row=1, sticky='news')

        def on_configure(event):
            self.canvas.itemconfigure('content_frame', width=event.width)
        self.canvas.bind('<Configure>', on_configure)

        self.fill_list()  # Add to content_frame

        ttk.Label(master_frame, padding=1, relief='ridge',
                  text='Top folder: ' + self.root).grid(
                      row=2, columnspan=2, sticky='ew')

        self.content_frame.update_idletasks()  # update bbox info

        for col in range(3):
            # align heading and content column widths
            width = max(head_frame.grid_bbox(col, col)[2],
                        self.content_frame.grid_bbox(col, col)[2])
            self.content_frame.columnconfigure(col, minsize=width)
            head_frame.columnconfigure(col, minsize=width)
        self.content_frame.columnconfigure(
            4, minsize=(self.content_frame.grid_bbox(4, 4)[2]))
        head_frame.columnconfigure(
            4, minsize=(self.content_frame.grid_bbox(4, 4)[2]
                        + master_frame.grid_bbox(1, 1)[2]))

        self.canvas.configure(scrollregion=self.canvas.bbox('all'))

    def create_ap_list(self):
        ''' create ap_list from self.txt '''
        self.ap_list = []
        for i in self.txt:
            i['txt'] = [i['txt']]
            while find_ap(i['txt'][-1]) != -1:
                start = find_ap(i['txt'][-1])
                stop = 2 + ((len(i['txt'][-1]) - 2)
                            if find_ap(i['txt'][-1][start + 3:], 'stop') == -1
                            else 1 + start + find_ap(
                                i['txt'][-1][start + 3:], 'stop'))
                while i['txt'][-1][stop - 1] in ' \n':
                    stop -= 1
                if (i['txt'][-1][stop - 1] in '-*+' and
                        i['txt'][-1][stop - 2] == '\n'):
                    stop -= 2
                if ('\n' in i['txt'][-1][start:stop] and
                    i['txt'][-1][stop - 1] == '.' and
                    i['txt'][-1][start:stop - 1].rpartition(
                        '\n')[2].isdigit()):
                    stop -= 1
                    while i['txt'][-1][stop - 1].isdigit():
                        stop -= 1
                while i['txt'][-1][stop - 1] in ' \n':
                    stop -= 1
                self.ap_list.append(AP(len(self.ap_list), i['file'],
                                       i['txt'][-1][start:stop]))
                i['txt'].append(self.ap_list[-1].key)
                i['txt'].append(i['txt'][-2][stop:])
                i['txt'][-3] = i['txt'][-3][:start]
        self.ap_list.sort(key=lambda i:
                          (i.sort, i.prio, i.post, i.file, i.key))
        self.on_file = []
        for a_p in self.ap_list:
            self.on_file.append(str(a_p))

    def add_line(self):
        ''' add line to list '''
        row_no = len(self.lbl)
        row = {}
        # Type
        row['type'] = ttk.Combobox(
            self.content_frame, width=5, values=('ap', 'idea', 'done'),
            justify='center', state='readonly')
        row['type'].bind('<Button>', self.other_click)
        row['type'].bind('<<ComboboxSelected>>',
                         lambda _: self.desc_format(row_no))
        row['type'].bind('<MouseWheel>', no_wheel)
        row['type'].bind('<4>', no_wheel)
        row['type'].bind('<5>', no_wheel)
        row['type'].grid(row=row_no, column=0, sticky='new')
        # Date
        row['date'] = ttk.Label(self.content_frame, background='white',
                                padding=1, relief='solid', anchor='center')
        row['date'].bind('<Button>', self.date)
        row['date'].bind('<MouseWheel>', self.wheel)
        row['date'].bind('<4>', self.wheel)
        row['date'].bind('<5>', self.wheel)
        row['date'].grid(row=row_no, column=1, sticky='new')
        # Prio
        row['prio'] = ttk.Combobox(
            self.content_frame, width=1, values=(1, 2, 3, 4, 5),
            justify='center', state='readonly')
        row['prio'].bind('<Button>', self.other_click)
        row['prio'].bind('<MouseWheel>', no_wheel)
        row['prio'].bind('<4>', no_wheel)
        row['prio'].bind('<5>', no_wheel)
        row['prio'].grid(row=row_no, column=2, sticky='new')
        # Description
        row['desc'] = Text(self.content_frame, font='Consolas 10',
                           height=1, width=20, wrap='word')
        row['desc'].bind('<Button>', self.descr_click)
        row['desc'].bind('<MouseWheel>', self.wheel)
        row['desc'].bind('<4>', self.wheel)
        row['desc'].bind('<5>', self.wheel)
        row['desc'].grid(row=row_no, column=3, sticky='new')
        # File
        row['file'] = ttk.Label(
            self.content_frame, anchor='nw', padding=1,
            text=self.ap_list[row_no].file[len(self.root):])
        row['file'].bind('<Button>', self.other_click)
        row['file'].grid(row=row_no, column=4, sticky='new')
        # Add to lbl
        self.lbl.append(row)

    def remove_line(self, row):
        ''' remove (surplus) line '''
        for lbl in ['type', 'date', 'prio', 'desc', 'file']:
            self.lbl[row][lbl].grid_remove()

    def desc_format(self, row):
        ''' strikethrogh dones '''
        if self.lbl[row]['type'].get() == 'done':
            self.lbl[row]['desc'].tag_add('strike', '1.0', 'end')
            self.lbl[row]['desc'].tag_configure('strike', overstrike=True)
        else:
            self.lbl[row]['desc'].tag_delete('strike')

    def fill_list(self):
        ''' display ap list '''
        while len(self.lbl) < len(self.ap_list):
            self.add_line()
        while len(self.lbl) > len(self.ap_list):
            self.remove_line(-1)
            del self.lbl[-1]
        for row, a_p in enumerate(self.ap_list):
            self.lbl[row]['type'].set(a_p.type)
            if a_p.post is not None:
                self.lbl[row]['date'].config(
                    text=a_p.post.isoformat())
            else:
                self.lbl[row]['date'].config(text='')
            self.lbl[row]['prio'].set(a_p.prio)
            self.lbl[row]['desc'].delete('1.0', 'end')
            self.lbl[row]['desc'].insert('1.0', a_p.cont)
            self.desc_format(row)
            self.lbl[row]['file'].config(
                text=a_p.file[len(self.root):])

    def wheel(self, event):
        ''' mouse wheel '''
        if event.num == '??':
            incr = -event.delta//120  # windows
        else:
            incr = 2*event.num - 9  # linux
        self.canvas.yview_scroll(incr, "units")

    def folder_reset(self):
        ''' reset top folder '''
        remove(join(expanduser("~"), 'pinta_planner.conf'))
        self.destroy()

    def clear_all(self):
        ''' view none '''
        for file in self.file_active.items():
            file[1].set(False)
        self.update()

    def set_all(self):
        ''' view all '''
        for file in self.file_active.items():
            file[1].set(True)
        self.update()

    def update(self):
        ''' edits => data, refresh '''
        for row, a_p in enumerate(self.ap_list):
            a_p.type = self.lbl[row]['type'].get()
            a_p.prio = int(self.lbl[row]['prio'].get())
            a_p.cont = self.lbl[row]['desc'].get('1.0', 'end')[:-1]
            self.lbl[row]['desc'].configure(height=1)
            self.lbl[row]['desc'].see('1.0')
            if not self.file_active[a_p.file].get():
                self.remove_line(row)
            else:
                self.lbl[row]['type'].grid()
                self.lbl[row]['date'].grid()
                self.lbl[row]['prio'].grid()
                self.lbl[row]['desc'].grid()
                self.lbl[row]['file'].grid()
        self.to_write = set()
        for ap_no, a_p in enumerate(self.ap_list):
            if str(a_p) != self.on_file[ap_no]:
                self.to_write.add(a_p.file)
        if self.to_write != set():
            self.title('*Pinta Planner')
        self.content_frame.update_idletasks()  # update bbox info
        self.canvas.configure(scrollregion=self.canvas.bbox('all'))

    def other_click(self, event):
        ''' click on something other '''
        del event
        self.update()

    def descr_click(self, event):
        ''' expand description field '''
        self.update()
        try:
            row = int(str(event.widget).partition('.!text')[2]) - 1
        except Exception:
            row = 0
        self.lbl[row]['desc'].configure(height=5)

    def date(self, event):
        ''' select date '''
        try:
            row = (int(str(event.widget).partition('.!label')[2])-1)//2
        except Exception:
            row = 0
        top = Toplevel(self)
        try:
            top.iconbitmap('favicon.ico')
        except Exception:
            pass
        if self.ap_list[row].post is not None:
            cal = Calendar(top, selectmode='day',
                           year=self.ap_list[row].post.year,
                           month=self.ap_list[row].post.month,
                           day=self.ap_list[row].post.day)
        else:
            cal = Calendar(top, selectmode='day')
        cal.grid(rowspan=4)

        def cancel():
            ''' don't change date '''
            top.destroy()
        ttk.Button(top, text="cancel",
                   command=cancel).grid(row=0, column=1, sticky='ns')

        def none():
            ''' delete date '''
            self.ap_list[row].post = None
            self.lbl[row]['date'].config(text='')
            top.destroy()
        ttk.Button(top, text="none",
                   command=none).grid(row=1, column=1, sticky='ns')

        def never():
            ''' date to infinity '''
            self.ap_list[row].post = date.fromisoformat('2999-12-31')
            self.lbl[row]['date'].config(
                text=self.ap_list[row].post.isoformat())
            top.destroy()
        ttk.Button(top, text="never",
                   command=never).grid(row=2, column=1, sticky='ns')

        def select():
            ''' select date '''
            self.ap_list[row].post = cal.selection_get()
            self.lbl[row]['date'].config(
                text=self.ap_list[row].post.isoformat())
            top.destroy()
        ttk.Button(top, text="select",
                   command=select).grid(row=3, column=1, sticky='ns')

    def ap_index(self, index):
        ''' get ap from index'''
        for i in self.ap_list:
            if index == i.key:
                out = i
        return out

    def save(self):
        ''' save & sort '''
        self.update()
        for i in self.txt:
            i['txt'][0] = (str(self.ap_index(i['txt'][0])) if
                           isinstance(i['txt'][0], int) else i['txt'][0])
            while len(i['txt']) > 1:
                i['txt'][0] += (str(self.ap_index(i['txt'].pop(1))) if
                                isinstance(i['txt'][1], int)
                                else i['txt'].pop(1))
            i['txt'] = i['txt'].pop(0)
            out = [i['txt']]
            while '\n' in out[-1]:
                temp = out[-1].partition('\n')
                out[-1] = temp[0] + temp[1]
                out.append(temp[2])
            if out[-1] == '':
                out.pop(-1)
            if i['file'] in self.to_write:
                with open(i['file'], 'w', encoding='utf-8') as out_file:
                    out_file.writelines(out)
        extract_events(self.txt, self.root)
        self.create_ap_list()  # since ap's may have been created or deleted
        self.title('Pinta Planner')
        self.fill_list()
        self.update()

MariaDB

I installed MariaDB following the outline in https://raspberrytips.com/install-mariadb-raspberry-pi and https://pimylifeup.com/raspberry-pi-mysql, and completed the geek-university.com online MySQL course, which got me started but left my with questions. The w3schools.com online SQL tutorial filled some gaps, and it's duckduckgo from here on.

For the interface between Pyhton and MariaDB I needed to install sudo apt install libmariadb-dev before pip3 install mariadb . I also had to include conn.auto_reconnect = True in my Flask Python code to avoid a server timeout problem.

Building a service with MariaDB involves presenting some sort of data. As I don't have any data of public interest to present, my service will have to involve user provided data, which puts the focus on data protection and session management.

I've built a small Time Clock LAMP service, and with that my goal to learn how to set up a LAMP server is completed. I'll keep an eye on it for maintenance.