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.
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.
#!/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.
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.
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.
First use of Python was for the fan speed control (but that's outside the LAMP scope).
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.
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).
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()
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.